Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs
T
Oliver Kieselbach 040e411958 feat(classification,calibration): rule 5a completed-assumed, max-lifetime grace skip, drift-radar floors
First real calibration read (2026-08-27, four days instrumented) drove three
data-backed changes:

- Rule 5a: past grace (or with the agent provably gone), Device Setup done +
  real-user desktop + zero app_install_failed reconciles to Succeeded
  "completed (assumed)" (r5_assumed). r5-Incomplete devices re-enrolled at
  2.6% - the succeeded background, not the ~29% failure level - and the
  sampled desktop half had clean app records. No desktop or any failed app
  stays Incomplete; rule 4 remains the immediate desktop+Hello reconcile.
- Rule 5 skips the AwaitingUser grace once either max-lifetime watchdog shape
  (enrollment_failed/agent_timeout, agent_shutting_down/max_lifetime) is in
  the stream: all five observed maxlife:r5_awaiting parks expired unhealed
  while live-agent parks upgraded 7/26. The maxlife ingest appends its
  trigger event to the read stream so the fact is never missed; late
  telemetry still heals a terminal verdict. WhiteGlove rule 1b keeps its
  park (its one episode healed).
- Drift radar: MinWindowHits raised to 10 (a 3-hit episode survived as
  noise), per-path share regression now needs >=5 baseline hits (new or
  renamed vocabulary cannot "regress"), and rule-shaped legacy:* paths count
  into the silence/classifier-verdict group sums on both sides of the
  2026-08-23 instrumentation cutover - dissolving the lift-124 silence
  artifact. Episodes below the fire floors clear themselves.

Backend only; the new path appears in the calibration matrix dynamically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 18:10:19 +02:00

784 lines
38 KiB
C#

using System;
using System.Collections.Generic;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for the timeout reclassification (tasks/enrollment-status-reclassification.md).
/// The maintenance sweep must stop labelling every silent session Failed: a session whose
/// Account Setup rollup reached all-succeeded reconciles to Succeeded; one that finished
/// Device Setup but whose user phase never completed is AwaitingUser (within grace) then
/// Incomplete; and a session silent before Device Setup with no explicit failure is Incomplete
/// — never Failed without an explicit failure signal.
/// </summary>
public class EnrollmentTimeoutClassifierTests
{
private static readonly DateTime Start = new(2026, 7, 6, 15, 0, 0, DateTimeKind.Utc);
private static EnrollmentEvent Evt(string type, string? message = null,
Dictionary<string, object>? data = null) => new()
{
EventType = type,
Timestamp = Start,
Source = "test",
Message = message ?? type,
Data = data!,
};
private static EnrollmentEvent HelloPolicy(bool enabled) =>
Evt("hello_policy_detected", data: new Dictionary<string, object> { ["helloEnabled"] = enabled.ToString() });
private static EnrollmentEvent EspConfig(bool skipUser) =>
Evt("esp_config_detected", data: new Dictionary<string, object> { ["skipUserStatusPage"] = skipUser });
private static EnrollmentEvent Esp(string message) => Evt("esp_provisioning_status", message);
private const string DeviceSetup44 = "ESP provisioning status: DeviceSetup — 4 of 4 subcategories completed";
private const string DeviceSetupFallback =
"ESP provisioning status: DeviceSetup — all 4 subcategories succeeded but categorySucceeded was not confirmed by Windows — treating as complete (fallback after 30s)";
private const string AccountSetup05 = "ESP provisioning status: AccountSetup — 0 of 5 subcategories completed";
private const string AccountSetup15 = "ESP provisioning status: AccountSetup — 1 of 5 subcategories completed";
private const string AccountSetup55 = "ESP provisioning status: AccountSetup — 5 of 5 subcategories completed";
private const string AccountSetupFallback =
"ESP provisioning status: AccountSetup — all 5 subcategories succeeded but categorySucceeded was not confirmed by Windows — treating as complete (fallback after 30s)";
// -------- ExtractRollup --------
[Fact]
public void ExtractRollup_empty_is_all_false()
{
var r = EnrollmentTimeoutClassifier.ExtractRollup(null);
Assert.False(r.DeviceSetupAllSucceeded);
Assert.False(r.AccountSetupAllSucceeded);
Assert.Equal(0, r.AccountSetupSucceededCount);
Assert.False(r.HasExplicitFailure);
Assert.False(r.HasTerminalComplete);
}
[Fact]
public void ExtractRollup_reads_device_and_account_rollups()
{
var r = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Esp(DeviceSetup44), Esp(AccountSetup05), Esp(AccountSetup15),
});
Assert.True(r.DeviceSetupAllSucceeded);
Assert.Equal(1, r.AccountSetupSucceededCount); // strongest observation wins
Assert.Equal(5, r.AccountSetupTotal);
Assert.False(r.AccountSetupAllSucceeded);
}
[Fact]
public void ExtractRollup_account_5of5_is_all_succeeded()
{
var r = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), Esp(AccountSetup55) });
Assert.True(r.AccountSetupAllSucceeded);
Assert.Equal(5, r.AccountSetupSucceededCount);
}
[Fact]
public void ExtractRollup_honours_fallback_complete_messages()
{
var r = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetupFallback), Esp(AccountSetupFallback) });
Assert.True(r.DeviceSetupAllSucceeded);
Assert.True(r.AccountSetupAllSucceeded);
}
[Fact]
public void ExtractRollup_detects_failure_and_complete_events()
{
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_failed") }).HasExplicitFailure);
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("esp_failure") }).HasExplicitFailure);
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_complete") }).HasTerminalComplete);
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("whiteglove_complete") }).HasTerminalComplete);
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("agent_emergency_break") }).HasAgentEmergencyBreak);
}
[Fact]
public void ExtractRollup_detects_desktop_hello_and_realmjoin_evidence()
{
var r = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Evt("desktop_arrived"), Evt("hello_provisioning_completed"), Evt("realmjoin_detected"),
});
Assert.True(r.DesktopArrived);
Assert.True(r.HelloResolved);
Assert.True(r.RealmJoinDetected);
Assert.False(r.RealmJoinResolved);
// hello_skipped is the other positive Hello terminal (agent raises HelloResolved for it).
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("hello_skipped") }).HelloResolved);
// Negative Hello terminals leave the agent waiting — must NOT count as resolved.
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("hello_provisioning_failed") }).HelloResolved);
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("hello_completion_timeout") }).HelloResolved);
// All RealmJoin gate terminals count as resolved (phase 110, aborted first
// deployment — session 224b2087 — or 60-min hard timeout).
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_resolved") }).RealmJoinResolved);
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_timeout") }).RealmJoinResolved);
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("realmjoin_first_deployment_incomplete") }).RealmJoinResolved);
}
[Fact]
public void ExtractRollup_reads_hello_policy_and_skip_user_esp_from_event_data()
{
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { HelloPolicy(enabled: false) }).HelloPolicyDisabled);
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { HelloPolicy(enabled: true) }).HelloPolicyDisabled);
// Contradicting observations resolve pessimistically → treat as enabled (keep demanding
// the Hello terminal).
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(
new[] { HelloPolicy(enabled: false), HelloPolicy(enabled: true) }).HelloPolicyDisabled);
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { EspConfig(skipUser: true) }).SkipUserEsp);
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { EspConfig(skipUser: false) }).SkipUserEsp);
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(
new[] { EspConfig(skipUser: true), EspConfig(skipUser: false) }).SkipUserEsp);
// No policy events at all → both false (pessimistic default).
var bare = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("agent_started") });
Assert.False(bare.HelloPolicyDisabled);
Assert.False(bare.SkipUserEsp);
}
[Fact]
public void Classify_emergency_break_skips_grace_and_is_Incomplete()
{
// Agent reported its absolute-age break → it's gone. Even DeviceSetup-done + well within grace
// must NOT wait as AwaitingUser; the honest verdict is Incomplete right now.
var (status, reason) = Classify(
new[] { Esp(DeviceSetup44), Esp(AccountSetup05), Evt("agent_emergency_break") }, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Incomplete, status);
Assert.Contains("emergency break", reason);
}
[Fact]
public void Classify_emergency_break_still_yields_to_a_real_completion()
{
// If the session actually completed, that wins over the break marker.
var (status, _) = Classify(
new[] { Esp(DeviceSetup44), Esp(AccountSetup55), Evt("agent_emergency_break") }, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
}
// -------- ClassifyTimedOutSession --------
private static (SessionStatus, string) Classify(IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 6, int grace = 72)
{
var (status, reason, _) = ClassifyWithRule(events, hoursSinceStart, grace);
return (status, reason);
}
private static (SessionStatus, string, string) ClassifyWithRule(IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 6, int grace = 72)
{
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
var now = Start.AddHours(hoursSinceStart);
return EnrollmentTimeoutClassifier.ClassifyTimedOutSession(rollup, Start, now, grace);
}
[Fact]
public void Classify_explicit_failure_is_Failed()
{
var (status, _) = Classify(new[] { Esp(DeviceSetup44), Evt("enrollment_failed") });
Assert.Equal(SessionStatus.Failed, status);
}
[Fact]
public void Classify_account_setup_complete_reconciles_to_Succeeded()
{
var (status, _) = Classify(new[] { Esp(DeviceSetup44), Esp(AccountSetup55) });
Assert.Equal(SessionStatus.Succeeded, status);
}
[Fact]
public void Classify_enrollment_complete_reconciles_to_Succeeded()
{
var (status, _) = Classify(new[] { Esp(DeviceSetup44), Esp(AccountSetup05), Evt("enrollment_complete") });
Assert.Equal(SessionStatus.Succeeded, status);
}
// -------- "user completed setup" reconcile (session 294ab5b4) --------
[Fact]
public void Classify_desktop_plus_hello_with_unresolved_realmjoin_reconciles_to_Succeeded()
{
// Session 294ab5b4 replay: DeviceSetup 4/4, AccountSetup frozen at 1/5 after the user
// hit the desktop, Hello provisioned, RealmJoin detected but never resolved — agent
// went silent mid-deployment. The user was provably there; "AwaitingUser" is wrong.
var (status, reason) = Classify(new[]
{
Esp(DeviceSetup44), Esp(AccountSetup15),
Evt("desktop_arrived"), Evt("hello_provisioning_completed"), Evt("realmjoin_detected"),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Contains("RealmJoin", reason);
}
[Fact]
public void Classify_reconcile_reason_carries_silence_timing_transparency()
{
// Transparency (session efbc17ff): the reconcile reason must name the last agent
// contact, the silence duration, and the exact moment the platform declared success —
// so a customer can tell "user powered the device off" apart from "declared too early".
var lastEvent = Start.AddMinutes(37); // agent last reported 37 min after start
var now = Start.AddHours(5); // sweep declared success 5h after start
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Esp(DeviceSetup44), Esp(AccountSetup15),
Evt("desktop_arrived"), Evt("hello_provisioning_completed"), Evt("realmjoin_detected"),
});
var (status, reason, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, Start, now, graceHours: 72, lastEventAtUtc: lastEvent);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Contains("RealmJoin", reason); // core verdict preserved
Assert.Contains($"Agent last reported {lastEvent:yyyy-MM-dd HH:mm} UTC", reason);
Assert.Contains("silent ~4h 23m", reason); // 5h - 37m
Assert.Contains($"declared this success at {now:yyyy-MM-dd HH:mm} UTC", reason);
}
[Fact]
public void Classify_reconcile_reason_timing_falls_back_to_start_when_last_event_unknown()
{
// No last-contact time → anchor on StartedAt (same fallback the stalled-marker uses),
// and the suffix is still emitted so the badge is never left timestamp-less.
var now = Start.AddHours(6);
var (_, reason) = Classify(new[] { Esp(DeviceSetup44), Esp(AccountSetup55) }, hoursSinceStart: 6);
Assert.Contains($"Agent last reported {Start:yyyy-MM-dd HH:mm} UTC", reason);
Assert.Contains("silent ~6h 0m", reason);
Assert.Contains($"declared this success at {now:yyyy-MM-dd HH:mm} UTC", reason);
}
[Fact]
public void Classify_desktop_plus_hello_without_realmjoin_reconciles_to_Succeeded()
{
// Both Classic completion prerequisites in and no gate pending: the agent died in the
// narrow window before it could report enrollment_complete.
var (status, reason) = Classify(new[]
{
Esp(DeviceSetup44), Esp(AccountSetup15),
Evt("desktop_arrived"), Evt("hello_provisioning_completed"),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Contains("silent before reporting completion", reason);
}
[Fact]
public void Classify_desktop_plus_hello_skipped_also_reconciles_to_Succeeded()
{
var (status, _) = Classify(new[]
{
Esp(DeviceSetup44), Evt("desktop_arrived"), Evt("hello_skipped"),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
}
[Fact]
public void Classify_hello_disabled_plus_skip_user_esp_plus_desktop_reconciles_to_Succeeded()
{
// Mirror of the agent's Hello-disabled fast-path: HelloPolicyEnabled==false +
// SkipUserEsp==true + desktop arrival completes on the device, so a silent session
// with the same evidence reconciles to Succeeded — no Hello terminal can ever exist
// in this configuration.
var (status, reason) = Classify(new[]
{
Esp(DeviceSetup44), Evt("desktop_arrived"),
HelloPolicy(enabled: false), EspConfig(skipUser: true),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Contains("User ESP skipped, Windows Hello disabled", reason);
}
[Fact]
public void Classify_hello_disabled_without_skip_user_esp_stays_AwaitingUser()
{
// Hello disabled but User ESP required: the agent's strong post-AccountSetup gate
// (session 08c99638) blocks its fast-path too — completion needs the AccountSetup
// rollup (rule 2) there, so the backend must keep waiting as well.
var (status, _) = Classify(new[]
{
Esp(DeviceSetup44), Esp(AccountSetup15), Evt("desktop_arrived"),
HelloPolicy(enabled: false), EspConfig(skipUser: false),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, status);
}
[Fact]
public void Classify_skip_user_esp_with_hello_enabled_still_requires_hello_terminal()
{
// SkipUserEsp only waives the AccountSetup evidence, never the Hello wizard — with
// Hello enabled the user still has to finish/skip the wizard on the device.
var (status, _) = Classify(new[]
{
Esp(DeviceSetup44), Evt("desktop_arrived"),
HelloPolicy(enabled: true), EspConfig(skipUser: true),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, status);
}
[Fact]
public void Classify_desktop_without_hello_terminal_stays_AwaitingUser()
{
// desktop_arrived alone is explicitly NOT a completion signal (design doc) — the user
// may still be mid Hello wizard / user phase. Falls through to the AwaitingUser rule.
var (status, _) = Classify(new[]
{
Esp(DeviceSetup44), Esp(AccountSetup15), Evt("desktop_arrived"),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, status);
}
[Fact]
public void Classify_explicit_failure_beats_desktop_plus_hello()
{
var (status, _) = Classify(new[]
{
Esp(DeviceSetup44), Evt("desktop_arrived"), Evt("hello_provisioning_completed"),
Evt("enrollment_failed"),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Failed, status);
}
[Fact]
public void Classify_emergency_break_beats_desktop_plus_hello()
{
// The break means the agent stayed alive to the 48h absolute cap WITHOUT completing —
// despite both prerequisites being in, something blocked completion for two days.
// That is not a success; the honest verdict stays Incomplete.
var (status, _) = Classify(new[]
{
Esp(DeviceSetup44), Evt("desktop_arrived"), Evt("hello_provisioning_completed"),
Evt("agent_emergency_break"),
}, hoursSinceStart: 50);
Assert.Equal(SessionStatus.Incomplete, status);
}
[Fact]
public void Classify_device_provisioned_user_phase_pending_within_grace_is_AwaitingUser()
{
// The dominant crcins.com case: DeviceSetup 4/4, AccountSetup 0/5, silent, 6h in.
var (status, reason) = Classify(new[] { Esp(DeviceSetup44), Esp(AccountSetup05) }, hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, status);
Assert.Contains("Device Setup completed", reason);
}
[Fact]
public void Classify_device_provisioned_after_grace_is_Incomplete()
{
var (status, _) = Classify(new[] { Esp(DeviceSetup44), Esp(AccountSetup05) }, hoursSinceStart: 80, grace: 72);
Assert.Equal(SessionStatus.Incomplete, status);
}
[Fact]
public void Classify_silent_before_device_setup_is_Incomplete_not_Failed()
{
// No DeviceSetup all-succeeded, no failure event → Incomplete (we don't know), not Failed.
var (status, _) = Classify(new[] { Evt("agent_started"), Esp("ESP provisioning status: DeviceSetup — 3 of 4 subcategories completed") });
Assert.Equal(SessionStatus.Incomplete, status);
}
// -------- Misclassification audit 2026-07-16 --------
[Fact]
public void ExtractRollup_agent_timeout_enrollment_failed_is_not_explicit_failure()
{
// The max-lifetime watchdog's enrollment_failed(failureType=agent_timeout) is "the agent
// gave up waiting", not a failure verdict — it must not poison rule 1 (tenant a53e67ec).
var timeout = Evt("enrollment_failed",
data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).HasExplicitFailure);
// Any OTHER failureType stays an explicit failure.
var genuine = Evt("enrollment_failed",
data: new Dictionary<string, object> { ["failureType"] = "esp_terminal" });
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { genuine }).HasExplicitFailure);
}
[Fact]
public void Classify_agent_timeout_terminalizes_immediately_instead_of_parking()
{
// The watchdog firing proves the agent is gone for good — an AwaitingUser park from this
// state is a countdown, not a wait: all 5 observed maxlife:r5_awaiting episodes expired
// unhealed (calibration read 2026-08-27). Still never Failed, and late straggler
// telemetry can heal the Incomplete (TryLateTelemetryReconcileAsync).
var timeout = Evt("enrollment_failed",
data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
var (status, reason) = Classify(new[] { Esp(DeviceSetup44), timeout }, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Incomplete, status);
Assert.Contains("max-lifetime watchdog", reason);
// The V2 shutdown shape carries the same fact.
var shutdown = Evt("agent_shutting_down",
data: new Dictionary<string, object> { ["reason"] = "max_lifetime" });
var (viaShutdown, _) = Classify(new[] { Esp(DeviceSetup44), shutdown }, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Incomplete, viaShutdown);
// WhiteGlove Part 2 (rule 1b) is deliberately untouched: a sealed/powered-off device
// between technician and end user stays AwaitingUser — that park DOES heal
// (the observed maxlife:r1b_awaiting episode resolved to Succeeded).
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), timeout });
var (wgStatus, _, wgRule) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, Start, Start.AddHours(6), 72, isPreProvisioned: true, resumedAt: Start.AddHours(1));
Assert.Equal(SessionStatus.AwaitingUser, wgStatus);
Assert.Equal(ClassifierRules.R1bWhiteGloveAwaiting, wgRule);
}
// -------- Rule 5a: completed (assumed) — calibration read 2026-08-27 --------
[Fact]
public void ExtractRollup_detects_app_failures_and_maxlife_timeout()
{
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("app_install_failed") }).HasAppInstallFailure);
var timeout = Evt("enrollment_failed", data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { timeout }).HasAgentMaxLifetimeTimeout);
var shutdown = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "max_lifetime" });
Assert.True(EnrollmentTimeoutClassifier.ExtractRollup(new[] { shutdown }).HasAgentMaxLifetimeTimeout);
// Other shutdown reasons say nothing about max lifetime.
var ctrlC = Evt("agent_shutting_down", data: new Dictionary<string, object> { ["reason"] = "ctrl_c" });
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { ctrlC }).HasAgentMaxLifetimeTimeout);
// A real failure event does not raise the app flag.
Assert.False(EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_failed") }).HasAppInstallFailure);
}
[Fact]
public void Classify_desktop_without_hello_past_grace_is_assumed_complete()
{
// The healthy half of the r5 population (drill-down 2026-08-27): desktop reached, apps
// clean, agent died before the Hello terminal. Past the full grace "user phase still
// running" is no longer a possible explanation — completed (assumed), not a red Incomplete.
var (status, reason, rule) = ClassifyWithRule(new[]
{
Esp(DeviceSetup44), Esp(AccountSetup15), Evt("desktop_arrived"),
}, hoursSinceStart: 80, grace: 72);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Equal(ClassifierRules.R5DesktopAssumed, rule);
Assert.Contains("treating the enrollment as completed", reason);
Assert.Contains("Agent last reported", reason); // silence-transparency clause
}
[Fact]
public void Classify_desktop_with_app_failure_past_grace_stays_Incomplete()
{
// "Completed" must not overclaim a session whose ESP payload provably failed
// (drill-down session b1edc6f8: desktop reached but .NET 3.5 install failed).
var (status, _) = Classify(new[]
{
Esp(DeviceSetup44), Evt("desktop_arrived"), Evt("app_install_failed"),
}, hoursSinceStart: 80, grace: 72);
Assert.Equal(SessionStatus.Incomplete, status);
}
[Fact]
public void Classify_maxlife_with_desktop_and_clean_apps_is_assumed_complete_within_grace()
{
// Agent provably gone + desktop + clean apps: no reason to park — the assumption is as
// good now as it would be after the 51h grace.
var timeout = Evt("enrollment_failed", data: new Dictionary<string, object> { ["failureType"] = "agent_timeout" });
var (status, _, rule) = ClassifyWithRule(new[]
{
Esp(DeviceSetup44), Evt("desktop_arrived"), timeout,
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Equal(ClassifierRules.R5DesktopAssumed, rule);
}
[Fact]
public void ExtractRollup_account_0ofN_observation_records_total()
{
// A "0 of 5" rollup was previously dropped entirely (the strongest-observation fold only
// kept n > 0), making the Incomplete reason read "0/0" — session 08ddbeec.
var r = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), Esp(AccountSetup05) });
Assert.Equal(0, r.AccountSetupSucceededCount);
Assert.Equal(5, r.AccountSetupTotal);
Assert.False(r.AccountSetupAllSucceeded);
}
[Fact]
public void Classify_incomplete_reason_shows_observed_account_rollup()
{
var (status, reason) = Classify(new[] { Esp(DeviceSetup44), Esp(AccountSetup05) }, hoursSinceStart: 80, grace: 72);
Assert.Equal(SessionStatus.Incomplete, status);
Assert.Contains("0/5", reason);
Assert.DoesNotContain("0/0", reason);
}
[Fact]
public void Classify_incomplete_reason_names_never_observed_account_rollup()
{
// No AccountSetup rollup at all — the reason must say so instead of a fabricated "0/0".
var (status, reason) = Classify(new[] { Esp(DeviceSetup44) }, hoursSinceStart: 80, grace: 72);
Assert.Equal(SessionStatus.Incomplete, status);
Assert.Contains("Account Setup progress never observed", reason);
}
// -------- ResolveGraceHours --------
[Theory]
[InlineData(null, null, 51)] // defaults: 48 + 3
[InlineData(0, null, 51)] // 0 override = auto-derive
[InlineData(0, 48, 51)] // explicit agent cap = default
[InlineData(0, 96, 99)] // bigger agent cap → grace follows (96 + 3)
[InlineData(0, 36, 51)] // override BELOW the real agent default is clamped up to 48 (agent isn't wired yet)
[InlineData(0, 0, 51)] // agent cap 0/invalid → fall back to default 48
[InlineData(90, 48, 90)] // override ABOVE the floor wins
[InlineData(30, 48, 51)] // override BELOW the floor is clamped up to the floor
public void ResolveGraceHours_floors_at_agent_cap_plus_buffer(int? configured, int? absoluteMax, int expected)
{
Assert.Equal(expected, EnrollmentTimeoutClassifier.ResolveGraceHours(configured, absoluteMax));
}
[Fact]
public void ResolveGraceHours_never_below_agent_absolute_cap()
{
// Property: whatever the inputs, the grace is at least the agent's absolute cap, so the backend
// never terminalizes Incomplete while the agent could still legitimately be enrolling.
foreach (var absMax in new int?[] { null, 6, 48, 72, 96 })
{
var cap = absMax.GetValueOrDefault(EnrollmentTimeoutClassifier.DefaultAbsoluteMaxSessionHours);
var grace = EnrollmentTimeoutClassifier.ResolveGraceHours(0, absMax);
Assert.True(grace >= cap, $"grace {grace} must be >= agent cap {cap}");
}
}
[Fact]
public void Classify_never_returns_Failed_without_explicit_failure()
{
// Guard the core invariant across a spread of non-failure inputs.
foreach (var events in new[]
{
new[] { Esp(DeviceSetup44), Esp(AccountSetup05) },
new[] { Esp(DeviceSetupFallback) },
new[] { Evt("agent_started") },
})
{
var (status, _) = Classify(events);
Assert.NotEqual(SessionStatus.Failed, status);
}
}
// -------- WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21) --------
private static readonly DateTime Resumed = Start.AddMinutes(20);
private static (SessionStatus, string) ClassifyWhiteGlove(
IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 6, int grace = 51)
{
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
var now = Start.AddHours(hoursSinceStart);
var (status, reason, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, Start, now, grace, isPreProvisioned: true, resumedAt: Resumed);
return (status, reason);
}
/// <summary>The fairstone event shape: Part 1 sealed, Part 2 resumed, nobody signed in.</summary>
private static EnrollmentEvent[] WgParkedEvents() => new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
};
[Fact]
public void ExtractRollup_separates_enrollment_complete_from_whiteglove_complete()
{
var wgOnly = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("whiteglove_complete") });
Assert.True(wgOnly.HasTerminalComplete);
Assert.False(wgOnly.HasEnrollmentComplete);
var real = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_complete") });
Assert.True(real.HasTerminalComplete);
Assert.True(real.HasEnrollmentComplete);
}
[Fact]
public void WhiteGloveGate_requires_preprovisioned_and_resume_and_device_setup()
{
var parked = EnrollmentTimeoutClassifier.ExtractRollup(WgParkedEvents());
Assert.True(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, Resumed));
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, false, Resumed));
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, null));
// Powered off before Device Setup ever provisioned → not the parking state.
var early = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Esp("ESP provisioning status: DeviceSetup — 3 of 4 subcategories completed"),
});
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(early, true, Resumed));
}
[Fact]
public void WhiteGloveGate_any_user_evidence_or_terminal_disarms_it()
{
foreach (var evidence in new[]
{
Esp(AccountSetup15), // Account Setup progressed → user was there
Evt("desktop_arrived"), // real-user desktop
Evt("hello_provisioning_completed"), // positive Hello terminal
Evt("enrollment_complete"), // real completion
Evt("enrollment_failed"), // explicit failure
})
{
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), evidence,
});
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(rollup, true, Resumed));
}
}
[Fact]
public void Classify_whiteglove_part2_parked_within_grace_is_AwaitingUser()
{
// The fairstone shape: technician powers the device off at the logon screen minutes
// after the reseal-reboot. Part-1 whiteglove_complete must NOT reconcile to Succeeded.
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, status);
Assert.Contains("sealed or powered off awaiting the end user", reason);
Assert.Contains("(Account Setup 0/5)", reason);
}
[Fact]
public void Classify_whiteglove_part2_parked_past_grace_reconciles_with_honest_reason()
{
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 60, grace: 51);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Contains("pre-provisioning (WhiteGlove Part 1) completed", reason);
Assert.DoesNotContain("Account Setup completed", reason);
}
[Fact]
public void Classify_whiteglove_part2_with_partial_user_progress_is_not_reconciled_on_part1_evidence()
{
// User signed in and Account Setup progressed to 1/5, then silence. The Part-1
// whiteglove_complete previously reconciled this to Succeeded claiming "Account Setup
// completed" — on a resumed session only a real enrollment_complete proves the outcome,
// so this honest shape falls through to the AwaitingUser/Incomplete rules.
var events = new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
};
var (withinGrace, _) = ClassifyWhiteGlove(events, hoursSinceStart: 6);
Assert.Equal(SessionStatus.AwaitingUser, withinGrace);
var (pastGrace, reason) = ClassifyWhiteGlove(events, hoursSinceStart: 60, grace: 51);
Assert.Equal(SessionStatus.Incomplete, pastGrace);
Assert.Contains("1/5", reason);
}
[Fact]
public void Classify_whiteglove_part2_with_desktop_and_hello_still_reconciles_via_user_evidence()
{
// Both Classic completion prerequisites in → the user provably finished; the
// user-completed reconcile (rule 4) owns this, not the parking gate.
var (status, reason) = ClassifyWhiteGlove(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
Evt("desktop_arrived"), Evt("hello_provisioning_completed"),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Contains("user completed setup", reason);
}
[Fact]
public void Classify_whiteglove_part2_real_enrollment_complete_still_reconciles_to_Succeeded()
{
var (status, _) = ClassifyWhiteGlove(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), Evt("enrollment_complete"),
}, hoursSinceStart: 6);
Assert.Equal(SessionStatus.Succeeded, status);
}
[Fact]
public void Classify_unresumed_session_keeps_terminal_complete_reconcile()
{
// No resumedAt (plain sessions, or a sealed Part 1 whose Pending write was lost):
// whiteglove_complete keeps reconciling to Succeeded exactly as before.
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
{
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
});
var (status, _, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, Start, Start.AddHours(6), 51, isPreProvisioned: true, resumedAt: null);
Assert.Equal(SessionStatus.Succeeded, status);
}
// -------- Self-deploying profile gate (kiosk tenant aebdce78, audit 2026-08-23) --------
private static (SessionStatus, string) ClassifySelfDeploying(
IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 3, int grace = 51)
{
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
var (status, reason, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
rollup, Start, Start.AddHours(hoursSinceStart), grace,
lastEventAtUtc: Start.AddMinutes(4), isSelfDeployingProfile: true);
return (status, reason);
}
// Session 195593e2 replay: DeviceSetup 4/4 (fallback-confirmed), AccountSetup registry
// at 0/5 (the IME false positive — user ESP never runs on this profile), Hello disabled,
// SkipUser=True, then silence after the post-ESP reboot. No user will ever sign in.
private static readonly EnrollmentEvent[] KioskSilentStream =
{
Esp(DeviceSetup44), Esp(AccountSetup05), Esp(DeviceSetupFallback),
HelloPolicy(enabled: false), EspConfig(skipUser: true),
};
[Fact]
public void IsSelfDeployingProvisioned_requires_flag_device_setup_and_no_failure()
{
var provisioned = EnrollmentTimeoutClassifier.ExtractRollup(KioskSilentStream);
Assert.True(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(provisioned, isSelfDeployingProfile: true));
Assert.False(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(provisioned, isSelfDeployingProfile: false));
var beforeDeviceSetup = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(AccountSetup05) });
Assert.False(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(beforeDeviceSetup, isSelfDeployingProfile: true));
var failed = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Esp(DeviceSetup44), Evt("enrollment_failed") });
Assert.False(EnrollmentTimeoutClassifier.IsSelfDeployingProvisioned(failed, isSelfDeployingProfile: true));
}
[Fact]
public void Classify_self_deploying_silent_after_device_setup_reconciles_to_Succeeded_within_grace()
{
var (status, reason) = ClassifySelfDeploying(KioskSilentStream, hoursSinceStart: 3);
Assert.Equal(SessionStatus.Succeeded, status);
Assert.Contains("self-deploying profile", reason);
Assert.Contains("no user / Account Setup phase", reason);
Assert.Contains("Agent last reported 2026-07-06 15:04 UTC", reason);
Assert.DoesNotContain("Account Setup completed", reason);
}
[Fact]
public void Classify_self_deploying_silent_after_device_setup_reconciles_to_Succeeded_past_grace()
{
// Grace is a user-phase concept; it does not apply when there is no user phase.
var (status, _) = ClassifySelfDeploying(KioskSilentStream, hoursSinceStart: 60, grace: 51);
Assert.Equal(SessionStatus.Succeeded, status);
}
[Fact]
public void Classify_same_stream_without_self_deploying_flag_keeps_user_phase_verdicts()
{
// Guard against the gate leaking into user-driven sessions: the identical event shape
// on a user-driven profile is still "awaiting user" within grace / Incomplete past it.
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(KioskSilentStream);
var (within, _, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(rollup, Start, Start.AddHours(3), 51);
var (past, _, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(rollup, Start, Start.AddHours(60), 51);
Assert.Equal(SessionStatus.AwaitingUser, within);
Assert.Equal(SessionStatus.Incomplete, past);
}
[Fact]
public void Classify_self_deploying_explicit_failure_stays_Failed()
{
var (status, _) = ClassifySelfDeploying(new[] { Esp(DeviceSetup44), Evt("enrollment_failed") });
Assert.Equal(SessionStatus.Failed, status);
}
[Fact]
public void Classify_self_deploying_silent_before_device_setup_is_Incomplete()
{
// The profile flag alone proves nothing — Device ESP must have finished.
var (status, _) = ClassifySelfDeploying(new[] { Esp(AccountSetup05), Evt("agent_started") });
Assert.Equal(SessionStatus.Incomplete, status);
}
}