v2 single-rail 8: first-session-fixes PR 1 — reducer correctness (Fixes 9/7/10/8)

Addresses the premature AwaitingHello + HelloSafety-baseline pathology observed in
session 30410cd7: on Classic V1 (SkipUser=false) enrollments the Device-ESP exit was
treated as the final ESP exit, arming HelloSafety from the wrong timestamp.

Fix 9 (foundation):
  - New DecisionSignalKind.EspConfigDetected + DecisionState.SkipUserEsp / SkipDeviceEsp
    facts with set-once semantics (per-fact, not per-signal).
  - Shared EspSkipConfigurationProbe reads HKLM\...\FirstSync once per tracker lifetime.
  - DeviceInfoCollector posts the signal alongside the esp_config_detected event.

Fix 7 (tracker guard — primary cut):
  - EspAndHelloTracker swallows the esp_exiting forward when SkipUser=false AND no
    AccountSetup activity yet. Injectable probes as test seams.

Fix 10 (deadline safety net):
  - HandleEspPhaseChangedV1(AccountSetup) cancels HelloSafety whenever the stage is
    bouncing back from AwaitingHello.

Fix 8 (reducer guards — belt-and-suspenders):
  - New ShouldTransitionToAwaitingHello helper: true only when AccountSetup entered OR
    SkipUser explicitly true.
  - HandleEspPhaseChangedV1(FinalizingSetup) + HandleEspExitingV1 gate the AwaitingHello
    promotion and HelloSafety arm behind the helper; observability facts still recorded
    on the suppressed path.

Tests (all green):
  - DecisionCore xUnit: 131 pass (+10 new — EspConfigDetectedHandlerTests 9,
    HelloSafetyBounceCancelTests 2, ClassicAwaitingHelloGuardTests 8).
  - Agent.V2.Core xUnit: 582 pass (+6 new — EspAndHelloTrackerFinalizingGuardTests).
  - Functions xUnit: 727 pass (regression check).
  - Solution build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-23 23:45:25 +02:00
parent 258f18d3dd
commit df2133cbb3
16 changed files with 1133 additions and 66 deletions
@@ -0,0 +1,173 @@
using System;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
{
/// <summary>
/// Plan §6 Fix 7 — the <see cref="EspAndHelloTracker"/> coordinator must NOT forward a
/// Shell-Core <c>esp_exiting</c> as a synthetic <c>EspPhaseChanged(FinalizingSetup)</c>
/// decision signal when the enrollment is a Classic V1 flow (SkipUser=false) and we have
/// not yet seen any AccountSetup activity — because that first exit is the Device-ESP to
/// Account-ESP handoff, not the true final ESP exit. Forwarding prematurely drives the
/// reducer into <c>AwaitingHello</c> and arms HelloSafety from the wrong baseline (see
/// session 30410cd7).
/// <para>
/// Exit forwards that must keep working:
/// </para>
/// <list type="bullet">
/// <item>SkipUser=true — device-only / full-skip flows; single esp_exiting IS final.</item>
/// <item>SkipUser unknown — defensive; never block when we don't know.</item>
/// <item>Non-<c>esp_exiting</c> reasons (e.g. <c>hello_wizard_started</c>) — the guard
/// only targets the ambiguous exit event.</item>
/// <item>SkipUser=false BUT AccountSetup activity observed — this is the post-AccountSetup
/// final exit.</item>
/// </list>
/// </summary>
public sealed class EspAndHelloTrackerFinalizingGuardTests
{
private static readonly DateTime Fixed = new DateTime(2026, 4, 23, 18, 57, 45, DateTimeKind.Utc);
private sealed class Fixture : IDisposable
{
public TempDirectory Tmp { get; } = new TempDirectory();
public AgentLogger Logger { get; }
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
public VirtualClock Clock { get; } = new VirtualClock(Fixed);
public EspAndHelloTracker BuildCoordinator(
bool? skipUser,
bool? skipDevice = false,
bool accountSetupSeen = false)
{
var trackerPost = new InformationalEventPost(new FakeSignalIngressSink(), Clock);
return new EspAndHelloTracker(
sessionId: "S1",
tenantId: "T1",
post: trackerPost,
logger: Logger,
skipConfigProbe: () => (skipUser, skipDevice),
accountSetupActivityProbe: () => accountSetupSeen);
}
public Fixture() { Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Debug); }
public void Dispose() { Tmp.Dispose(); }
}
[Fact]
public void ForwardsFinalizing_whenSkipUserTrue_pluginsEspExit()
{
// SkipUser=true → single Shell-Core esp_exiting IS the final exit. Forward.
using var f = new Fixture();
using var coordinator = f.BuildCoordinator(skipUser: true);
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
coordinator.TriggerFinalizingSetupPhaseForTest("esp_exiting");
var posted = Assert.Single(f.Ingress.Posted);
Assert.Equal(DecisionSignalKind.EspPhaseChanged, posted.Kind);
Assert.Equal(EnrollmentPhase.FinalizingSetup.ToString(), posted.Payload![SignalPayloadKeys.EspPhase]);
Assert.Equal("esp_exiting", posted.Payload["reason"]);
}
[Fact]
public void DoesNotForwardFinalizing_whenSkipUserFalseAndNoAccountSetup()
{
// Classic V1 intermediate Device-ESP exit: SkipUser=false, AccountSetup not yet seen.
// Swallow — the real final exit arrives later.
using var f = new Fixture();
using var coordinator = f.BuildCoordinator(skipUser: false, accountSetupSeen: false);
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
coordinator.TriggerFinalizingSetupPhaseForTest("esp_exiting");
Assert.Empty(f.Ingress.Posted);
}
[Fact]
public void ForwardsFinalizing_whenSkipUserFalseAndAccountSetupAlreadySeen()
{
// Post-AccountSetup second esp_exiting — genuine final exit. Forward.
using var f = new Fixture();
using var coordinator = f.BuildCoordinator(skipUser: false, accountSetupSeen: true);
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
coordinator.TriggerFinalizingSetupPhaseForTest("esp_exiting");
var posted = Assert.Single(f.Ingress.Posted);
Assert.Equal(DecisionSignalKind.EspPhaseChanged, posted.Kind);
Assert.Equal(EnrollmentPhase.FinalizingSetup.ToString(), posted.Payload![SignalPayloadKeys.EspPhase]);
}
[Fact]
public void ForwardsFinalizing_whenSkipUserUnknown_regardlessOfAccountSetup()
{
// Defensive: unknown skip-flag → forward (preserves pre-fix behavior, no new breakage).
using var f = new Fixture();
using var coordinator = f.BuildCoordinator(skipUser: null, accountSetupSeen: false);
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
coordinator.TriggerFinalizingSetupPhaseForTest("esp_exiting");
Assert.Single(f.Ingress.Posted);
}
[Fact]
public void ForwardsFinalizing_whenHelloWizardStarted_regardlessOfSkipUser()
{
// Only esp_exiting is ambiguous. hello_wizard_started and other reasons must keep
// forwarding unconditionally — SkipUser does not affect Hello-wizard detection.
using var f = new Fixture();
using var coordinator = f.BuildCoordinator(skipUser: false, accountSetupSeen: false);
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
coordinator.TriggerFinalizingSetupPhaseForTest("hello_wizard_started");
var posted = Assert.Single(f.Ingress.Posted);
Assert.Equal(DecisionSignalKind.EspPhaseChanged, posted.Kind);
Assert.Equal("hello_wizard_started", posted.Payload!["reason"]);
}
[Fact]
public void SwallowedFirstExit_doesNotBlockSecondExitAfterAccountSetup()
{
// Full Classic V1 sequence: Device-ESP esp_exiting (swallowed) → AccountSetup
// activity appears → second esp_exiting (forwarded). Mirrors the Fix-7 production
// sequence for session 30410cd7.
using var f = new Fixture();
bool accountSetupSeen = false;
var trackerPost = new InformationalEventPost(new FakeSignalIngressSink(), f.Clock);
using var coordinator = new EspAndHelloTracker(
sessionId: "S1",
tenantId: "T1",
post: trackerPost,
logger: f.Logger,
skipConfigProbe: () => ((bool?)false, (bool?)false),
accountSetupActivityProbe: () => accountSetupSeen);
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
// First exit — swallowed.
coordinator.TriggerFinalizingSetupPhaseForTest("esp_exiting");
Assert.Empty(f.Ingress.Posted);
// Now AccountSetup activity arrives (registry picks up the subcategory JSON).
accountSetupSeen = true;
// Second exit — forwarded.
coordinator.TriggerFinalizingSetupPhaseForTest("esp_exiting");
var posted = Assert.Single(f.Ingress.Posted);
Assert.Equal(DecisionSignalKind.EspPhaseChanged, posted.Kind);
Assert.Equal(EnrollmentPhase.FinalizingSetup.ToString(), posted.Payload![SignalPayloadKeys.EspPhase]);
}
}
}
@@ -35,12 +35,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private readonly int _modernDeploymentBackfillLookbackMinutes;
private readonly string _stateDirectory;
private readonly int[] _modernDeploymentHarmlessEventIds;
private readonly Func<(bool? skipUser, bool? skipDevice)> _skipConfigProbe;
private readonly Func<bool> _accountSetupActivityProbe;
private HelloTracker _helloTracker;
private ShellCoreTracker _shellCoreTracker;
private ProvisioningStatusTracker _provisioningTracker;
private ModernDeploymentTracker _modernDeploymentTracker;
// Plan §6 Fix 7 — lazy-cached skip-user flag from HKLM\...\FirstSync\SkipUserStatusPage.
// Read once per tracker lifetime on first esp_exiting forward; null until read.
private bool? _skipUserEspCached;
private bool _skipConfigProbed;
private readonly object _skipConfigLock = new object();
/// <summary>
/// Callback invoked when Hello provisioning completes (successfully, failed, skipped, or timeout).
/// Based on events 300/301/362 only — NOT on event 360 (which is just a snapshot).
@@ -121,7 +129,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
bool modernDeploymentBackfillEnabled = true,
int modernDeploymentBackfillLookbackMinutes = 30,
string stateDirectory = null,
int[] modernDeploymentHarmlessEventIds = null)
int[] modernDeploymentHarmlessEventIds = null,
Func<(bool? skipUser, bool? skipDevice)> skipConfigProbe = null,
Func<bool> accountSetupActivityProbe = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
@@ -134,6 +144,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_modernDeploymentBackfillLookbackMinutes = modernDeploymentBackfillLookbackMinutes;
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
_modernDeploymentHarmlessEventIds = modernDeploymentHarmlessEventIds;
// Test seam: allow injection of a fake skip-config reader; defaults to the real
// registry probe so production never has to think about this parameter.
_skipConfigProbe = skipConfigProbe ?? (() => EspSkipConfigurationProbe.Read(_logger));
// Test seam: allow injection of a fake AccountSetup-activity probe. Production uses
// the provisioning tracker's live registry-derived flag.
_accountSetupActivityProbe = accountSetupActivityProbe ?? (() => _provisioningTracker?.HasAccountSetupActivity == true);
}
// =====================================================================
@@ -296,10 +312,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private void OnFinalizingSetupPhaseTriggered(object sender, string reason)
{
// Plan §6 Fix 7 — on Classic V1 enrollments (SkipUser=false) Shell-Core emits TWO
// esp_exiting (62407) events: one when Device-ESP hands off to Account-ESP, and one
// at the true final exit. Forwarding the first one as EspPhaseChanged(Finalizing)
// drives the reducer into AwaitingHello prematurely and arms HelloSafety from the
// wrong baseline (see session 30410cd7 where the deadline would have fired first).
// Swallow the intermediate forward when SkipUser is explicitly false AND the
// provisioning tracker has not yet seen any AccountSetup activity.
if (string.Equals(reason, "esp_exiting", StringComparison.OrdinalIgnoreCase)
&& IsIntermediateDeviceEspExit())
{
_logger.Info(
"EspAndHelloTracker: swallowing intermediate Device-ESP esp_exiting (SkipUser=false, no AccountSetup activity yet) — waiting for real final exit");
return;
}
try { FinalizingSetupPhaseTriggered?.Invoke(this, reason); }
catch (Exception ex) { _logger.Error("Error forwarding FinalizingSetupPhaseTriggered", ex); }
}
/// <summary>
/// Plan §6 Fix 7 — returns <c>true</c> when the current <c>esp_exiting</c> forward
/// should be suppressed because it is the Device-ESP intermediate exit on a Classic
/// (SkipUser=false) enrollment that has not yet reached the AccountSetup phase.
/// <para>
/// Returns <c>false</c> (i.e. "forward the signal") when any of the following hold:
/// </para>
/// <list type="bullet">
/// <item>SkipUser is unknown or explicitly <c>true</c> (device-only / SkipUser flow —
/// AwaitingHello is legitimately reachable directly after Device-ESP).</item>
/// <item>The provisioning tracker has observed AccountSetup activity (registry JSON
/// under <c>AccountSetupCategory.Status</c> has surfaced at least one subcategory),
/// i.e. the current exit is the final post-Account-ESP one.</item>
/// </list>
/// </summary>
private bool IsIntermediateDeviceEspExit()
{
// If AccountSetup activity has appeared, we're at the true final exit.
bool accountSetupSeen;
try { accountSetupSeen = _accountSetupActivityProbe(); }
catch (Exception ex)
{
_logger.Debug($"EspAndHelloTracker: account-setup-activity probe threw: {ex.Message}");
accountSetupSeen = false;
}
if (accountSetupSeen) return false;
var skipUser = GetSkipUserEspCached();
// Unknown or explicitly-skipping => forward as before. Only block when we are
// certain the enrollment does NOT skip the user ESP (i.e. a second esp_exiting is
// expected).
return skipUser == false;
}
private bool? GetSkipUserEspCached()
{
lock (_skipConfigLock)
{
if (_skipConfigProbed) return _skipUserEspCached;
_skipConfigProbed = true;
try
{
var (skipUser, _) = _skipConfigProbe();
_skipUserEspCached = skipUser;
}
catch (Exception ex)
{
_logger.Debug($"EspAndHelloTracker: skip-config probe threw: {ex.Message}");
_skipUserEspCached = null;
}
return _skipUserEspCached;
}
}
// Test seam — invokes the guarded forward path without driving the underlying ShellCore
// event-log watcher. Mirrors the arg signature of the live event handler. Internal so
// only the in-repo test project (InternalsVisibleTo) can use it.
internal void TriggerFinalizingSetupPhaseForTest(string reason) =>
OnFinalizingSetupPhaseTriggered(this, reason);
private void OnWhiteGloveCompleted(object sender, EventArgs e)
{
try { WhiteGloveCompleted?.Invoke(this, EventArgs.Empty); }
@@ -0,0 +1,98 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Logging;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Plan §6 Fix 7/9 — single source of truth for the Autopilot-enrollment ESP skip flags
/// (<c>SkipUserStatusPage</c> / <c>SkipDeviceStatusPage</c>) that the MDM CSP writes under
/// <c>HKLM\SOFTWARE\Microsoft\Enrollments\{guid}\FirstSync</c> during FirstSync.
/// <para>
/// Two consumers:
/// <list type="bullet">
/// <item><see cref="Telemetry.DeviceInfo.DeviceInfoCollector"/> — emits the
/// <c>esp_config_detected</c> event + the <c>EspConfigDetected</c> decision signal
/// at agent start and at first DeviceSetup-phase detection.</item>
/// <item><see cref="EspAndHelloTracker"/> — guards the coordinator's
/// synthetic <c>EspPhaseChanged(FinalizingSetup)</c> forward: a Classic V1 enrollment
/// with <c>SkipUser=false</c> sees TWO <c>esp_exiting</c> events (Device-ESP exit and
/// Account-ESP exit) and only the second is a true final exit.</item>
/// </list>
/// Reading from the same authoritative registry location in both places keeps the reducer's
/// <see cref="AutopilotMonitor.DecisionCore.State.DecisionState.SkipUserEsp"/> fact and the
/// tracker's guard in lockstep — the alternative (passing the value around) has too many
/// subtle lifecycle order issues to be safe.
/// </para>
/// <para>
/// CSP values: <c>0xFFFFFFFF</c> = skip (ESP page not shown), <c>0</c> = show, key missing
/// = unknown. Defensive interpretation: non-null AND non-zero = skip.
/// </para>
/// </summary>
internal static class EspSkipConfigurationProbe
{
internal const string EnrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
internal const int MdmEnrollmentType = 6;
/// <summary>
/// Reads the current device's <c>SkipUserStatusPage</c> / <c>SkipDeviceStatusPage</c>
/// flags. Returns <c>(null, null)</c> when the enrollment key is missing or unreadable —
/// callers must treat <c>null</c> as "unknown", not "false".
/// </summary>
/// <param name="logger">Optional logger — Debug-level trace only; no warn/error.</param>
public static (bool? skipUser, bool? skipDevice) Read(AgentLogger logger = null)
{
bool? skipUser = null;
bool? skipDevice = null;
try
{
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(EnrollmentsKeyPath))
{
if (enrollmentsKey == null)
{
logger?.Debug("EspSkipConfigurationProbe: Enrollments registry key not found");
return (null, null);
}
foreach (var guid in enrollmentsKey.GetSubKeyNames())
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(guid))
{
if (enrollmentKey == null) continue;
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
if (enrollmentType == null || Convert.ToInt32(enrollmentType) != MdmEnrollmentType)
continue;
using (var firstSyncKey = enrollmentKey.OpenSubKey("FirstSync"))
{
if (firstSyncKey == null)
{
logger?.Debug($"EspSkipConfigurationProbe: FirstSync subkey not found for enrollment {guid}");
return (null, null);
}
var rawSkipUser = firstSyncKey.GetValue("SkipUserStatusPage");
if (rawSkipUser != null)
skipUser = Convert.ToInt32(rawSkipUser) != 0;
var rawSkipDevice = firstSyncKey.GetValue("SkipDeviceStatusPage");
if (rawSkipDevice != null)
skipDevice = Convert.ToInt32(rawSkipDevice) != 0;
}
break;
}
}
}
}
catch (Exception ex)
{
logger?.Debug($"EspSkipConfigurationProbe: read threw: {ex.Message}");
}
return (skipUser, skipDevice);
}
}
}
@@ -8,7 +8,10 @@ using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
@@ -503,76 +506,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
/// <summary>
/// Reads ESP skip configuration from the enrollment FirstSync registry key.
/// CSP SkipUserStatusPage/SkipDeviceStatusPage are written as DWORD:
/// 0xFFFFFFFF = skip (ESP page not shown), 0 = show, key missing = unknown.
/// We use defensive interpretation: non-null AND non-zero = skip.
/// Reads ESP skip configuration from the enrollment FirstSync registry key via the
/// shared <see cref="EspSkipConfigurationProbe"/> (also used by
/// <see cref="Monitoring.Enrollment.SystemSignals.EspAndHelloTracker"/> so the tracker's
/// SkipUser guard and the reducer's <c>SkipUserEsp</c> fact stay in lockstep, plan §6 Fix 7/9).
/// </summary>
private (bool? skipUserStatusPage, bool? skipDeviceStatusPage) CollectEspConfiguration()
{
bool? skipUser = null;
bool? skipDevice = null;
string enrollmentGuid = null;
try
{
const string enrollmentsKeyPath = @"SOFTWARE\Microsoft\Enrollments";
using (var enrollmentsKey = Registry.LocalMachine.OpenSubKey(enrollmentsKeyPath))
{
if (enrollmentsKey == null)
{
_logger.Debug("EnrollmentTracker: Enrollments registry key not found — ESP config unknown");
return (null, null);
}
// Find the MDM enrollment entry (EnrollmentType == 6)
foreach (var guid in enrollmentsKey.GetSubKeyNames())
{
using (var enrollmentKey = enrollmentsKey.OpenSubKey(guid))
{
if (enrollmentKey == null) continue;
var enrollmentType = enrollmentKey.GetValue("EnrollmentType");
if (enrollmentType == null || Convert.ToInt32(enrollmentType) != 6)
continue;
enrollmentGuid = guid;
// Read FirstSync subkey
using (var firstSyncKey = enrollmentKey.OpenSubKey("FirstSync"))
{
if (firstSyncKey == null)
{
_logger.Debug($"EnrollmentTracker: FirstSync subkey not found for enrollment {guid}");
break;
}
var rawSkipUser = firstSyncKey.GetValue("SkipUserStatusPage");
if (rawSkipUser != null)
skipUser = Convert.ToInt32(rawSkipUser) != 0;
var rawSkipDevice = firstSyncKey.GetValue("SkipDeviceStatusPage");
if (rawSkipDevice != null)
skipDevice = Convert.ToInt32(rawSkipDevice) != 0;
}
break; // Found the MDM enrollment entry
}
}
}
(skipUser, skipDevice) = EspSkipConfigurationProbe.Read(_logger);
var data = new Dictionary<string, object>
{
{ "source", "registry_firstsync" }
};
if (enrollmentGuid != null) data["enrollmentGuid"] = enrollmentGuid;
if (skipUser.HasValue) data["skipUserStatusPage"] = skipUser.Value;
if (skipDevice.HasValue) data["skipDeviceStatusPage"] = skipDevice.Value;
var summary = $"SkipUser={skipUser?.ToString() ?? "unknown"}, SkipDevice={skipDevice?.ToString() ?? "unknown"}";
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
EmitDeviceInfoEvent("esp_config_detected", $"ESP configuration: {summary}", data);
PostEspConfigDetectedSignal(skipUser, skipDevice);
}
catch (Exception ex)
{
@@ -764,6 +722,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
});
}
/// <summary>
/// Plan §6 Fix 9 — post an <see cref="DecisionSignalKind.EspConfigDetected"/> decision
/// signal alongside the user-facing <c>esp_config_detected</c> event so the reducer's
/// <see cref="AutopilotMonitor.DecisionCore.State.DecisionState.SkipUserEsp"/> /
/// <see cref="AutopilotMonitor.DecisionCore.State.DecisionState.SkipDeviceEsp"/> facts get
/// populated. Fix 8's reducer guards then block premature <c>AwaitingHello</c> transitions
/// on Classic (SkipUser=false) enrollments.
/// <para>
/// Fire-once per collector instance: re-collection paths
/// (<see cref="CollectAtEnrollmentStart"/>) would otherwise post the signal multiple times
/// and clutter the reducer journal with no-op steps.
/// </para>
/// </summary>
private void PostEspConfigDetectedSignal(bool? skipUser, bool? skipDevice)
{
if (_signalIngress == null || _clock == null) return;
if (_espConfigSignalPosted) return;
if (skipUser == null && skipDevice == null) return;
_espConfigSignalPosted = true;
var payload = new Dictionary<string, string>(StringComparer.Ordinal);
if (skipUser.HasValue)
payload[SignalPayloadKeys.SkipUserEsp] = skipUser.Value ? "true" : "false";
if (skipDevice.HasValue)
payload[SignalPayloadKeys.SkipDeviceEsp] = skipDevice.Value ? "true" : "false";
var derivationInputs = new Dictionary<string, string>(payload, StringComparer.Ordinal)
{
["source"] = "registry_firstsync",
};
_signalIngress.Post(
kind: DecisionSignalKind.EspConfigDetected,
occurredAtUtc: _clock.UtcNow,
sourceOrigin: "DeviceInfoCollector",
evidence: new Evidence(
kind: EvidenceKind.Raw,
identifier: "esp_config_detected",
summary: $"SkipUser={skipUser?.ToString() ?? "unknown"}, SkipDevice={skipDevice?.ToString() ?? "unknown"}",
derivationInputs: derivationInputs),
payload: payload);
}
private static string TryFormatJson(string input)
{
if (string.IsNullOrWhiteSpace(input))
@@ -8,6 +8,8 @@ using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
@@ -28,6 +30,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
private readonly string _tenantId;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
private readonly ISignalIngressSink _signalIngress;
private readonly IClock _clock;
private bool _espConfigSignalPosted;
private const string RegKeyWindowsCurrentVersion = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion";
@@ -35,12 +40,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
string sessionId,
string tenantId,
InformationalEventPost post,
AgentLogger logger)
AgentLogger logger,
ISignalIngressSink signalIngress = null,
IClock clock = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_signalIngress = signalIngress;
_clock = clock;
}
/// <summary>
@@ -426,8 +426,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
if (clock == null) throw new ArgumentNullException(nameof(clock));
_logger = logger;
var post = new InformationalEventPost(ingress, clock);
// Plan §6 Fix 9 — the collector also posts an EspConfigDetected decision signal
// when it reads the FirstSync SkipUser/SkipDevice registry values, so that Fix 8's
// reducer guards have the SkipUserEsp/SkipDeviceEsp state facts to read.
_collector = new Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector(
sessionId, tenantId, post, logger);
sessionId, tenantId, post, logger, ingress, clock);
}
public void Start()
@@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Plan §6 Fix 8 — the Classic reducer must only promote to <see cref="SessionStage.AwaitingHello"/>
/// when the promotion is legitimate: either <see cref="DecisionState.AccountSetupEnteredUtc"/>
/// is set (i.e. we're past the post-Account-ESP final exit), or
/// <see cref="DecisionState.SkipUserEsp"/> is explicitly <c>true</c> (device-only / SkipUser
/// flow where a single esp_exiting IS the final one).
/// <para>
/// Applies symmetrically to <see cref="DecisionSignalKind.EspPhaseChanged"/>(FinalizingSetup)
/// AND <see cref="DecisionSignalKind.EspExiting"/>. On an early / intermediate signal the
/// stage stays unchanged, no HelloSafety is armed, and observability facts
/// (<see cref="DecisionState.FinalizingEnteredUtc"/> / <see cref="DecisionState.EspFinalExitUtc"/>)
/// are still recorded so classifiers see the full picture.
/// </para>
/// </summary>
public sealed class ClassicAwaitingHelloGuardTests
{
private static readonly DateTime Fixed = new DateTime(2026, 4, 23, 18, 57, 45, DateTimeKind.Utc);
// ========================================================= EspPhaseChanged (Finalizing)
[Fact]
public void EspPhaseChanged_FinalizingSetup_withSkipUserTrue_transitionsToAwaitingHello()
{
var engine = new DecisionEngine();
var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: true);
var step = engine.Reduce(state, MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 5));
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
Assert.True(step.Transition.Taken);
Assert.Equal(SessionStage.EspDeviceSetup, step.Transition.FromStage);
Assert.Equal(SessionStage.AwaitingHello, step.Transition.ToStage);
Assert.NotNull(step.NewState.FinalizingEnteredUtc);
Assert.Equal(EnrollmentPhase.FinalizingSetup, step.NewState.CurrentEnrollmentPhase!.Value);
}
[Fact]
public void EspPhaseChanged_FinalizingSetup_withSkipUserFalse_beforeAccountSetup_keepsStage()
{
// Classic intermediate Device-ESP exit synthesized as FinalizingSetup. Guard blocks
// the AwaitingHello promotion; the stage stays on EspDeviceSetup.
var engine = new DecisionEngine();
var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: false);
var step = engine.Reduce(state, MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 5));
Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage);
Assert.True(step.Transition.Taken);
Assert.Equal(step.Transition.FromStage, step.Transition.ToStage);
// Observability is still recorded.
Assert.NotNull(step.NewState.FinalizingEnteredUtc);
Assert.Equal(EnrollmentPhase.FinalizingSetup, step.NewState.CurrentEnrollmentPhase!.Value);
// No HelloSafety on this path (HandleEspPhaseChangedV1 never arms it — but verify
// anyway for belt-and-suspenders).
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
}
[Fact]
public void EspPhaseChanged_FinalizingSetup_withSkipUserFalse_afterAccountSetup_transitionsToAwaitingHello()
{
// Post-Account-ESP final exit: guard allows promotion.
var engine = new DecisionEngine();
var state = BuildState(stage: SessionStage.EspAccountSetup, skipUser: false, accountSetupEntered: true);
var step = engine.Reduce(state, MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 9));
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
Assert.NotNull(step.NewState.FinalizingEnteredUtc);
}
[Fact]
public void EspPhaseChanged_FinalizingSetup_withSkipUserUnknown_beforeAccountSetup_keepsStage()
{
// Defensive: unknown SkipUser does NOT unlock AwaitingHello. Otherwise a missing
// EspConfigDetected signal (e.g. registry race at enrollment start) would reopen the
// original bug.
var engine = new DecisionEngine();
var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: null);
var step = engine.Reduce(state, MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 3));
Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage);
}
// =============================================================== EspExiting (real arm)
[Fact]
public void EspExiting_withSkipUserTrue_transitionsToAwaitingHello_armsHelloSafety()
{
var engine = new DecisionEngine();
var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: true);
var step = engine.Reduce(state, MakeExitingSignal(ordinal: 5));
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
Assert.NotNull(step.NewState.EspFinalExitUtc);
Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
Assert.Contains(step.Effects,
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.HelloSafety);
}
[Fact]
public void EspExiting_withSkipUserFalse_beforeAccountSetup_staysInStage_noHelloSafety_butRecordsFact()
{
// Device-ESP intermediate exit. Classic's HandleEspExitingV1 must preserve the
// EspFinalExitUtc fact for observability but block the stage change + deadline arm.
var engine = new DecisionEngine();
var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: false);
var step = engine.Reduce(state, MakeExitingSignal(ordinal: 5));
Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage);
Assert.NotNull(step.NewState.EspFinalExitUtc);
Assert.Empty(step.NewState.Deadlines);
Assert.Empty(step.Effects);
Assert.True(step.Transition.Taken);
}
[Fact]
public void EspExiting_withSkipUserFalse_afterAccountSetup_transitionsToAwaitingHello_armsHelloSafety()
{
var engine = new DecisionEngine();
var state = BuildState(
stage: SessionStage.EspAccountSetup,
skipUser: false,
accountSetupEntered: true);
var step = engine.Reduce(state, MakeExitingSignal(ordinal: 9));
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
}
[Fact]
public void EspExiting_withSkipUserUnknown_beforeAccountSetup_staysInStage()
{
var engine = new DecisionEngine();
var state = BuildState(stage: SessionStage.EspDeviceSetup, skipUser: null);
var step = engine.Reduce(state, MakeExitingSignal(ordinal: 5));
Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage);
Assert.Empty(step.NewState.Deadlines);
}
// ====================================================================== test helpers
private static DecisionState BuildState(
SessionStage stage,
bool? skipUser,
bool accountSetupEntered = false)
{
var builder = DecisionState.CreateInitial("s", "t")
.ToBuilder()
.WithStage(stage)
.WithStepIndex(2)
.WithLastAppliedSignalOrdinal(1);
if (skipUser.HasValue) builder.SkipUserEsp = new SignalFact<bool>(skipUser.Value, 0);
if (accountSetupEntered) builder.AccountSetupEnteredUtc = new SignalFact<DateTime>(Fixed.AddMinutes(-1), 1);
return builder.Build();
}
private static DecisionSignal MakePhaseSignal(EnrollmentPhase phase, long ordinal) =>
new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: DecisionSignalKind.EspPhaseChanged,
kindSchemaVersion: 1,
occurredAtUtc: Fixed,
sourceOrigin: "EspAndHelloTracker",
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "esp-phase-detector-v1",
summary: phase.ToString(),
derivationInputs: new Dictionary<string, string>
{
[SignalPayloadKeys.EspPhase] = phase.ToString(),
}),
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EspPhase] = phase.ToString(),
});
private static DecisionSignal MakeExitingSignal(long ordinal) =>
new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: DecisionSignalKind.EspExiting,
kindSchemaVersion: 1,
occurredAtUtc: Fixed,
sourceOrigin: "ShellCoreTracker",
evidence: new Evidence(EvidenceKind.Raw, "esp_exiting", "ESP phase exiting"));
}
}
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Serialization;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Plan §6 Fix 9 — <see cref="DecisionSignalKind.EspConfigDetected"/> populates the
/// <see cref="DecisionState.SkipUserEsp"/> / <see cref="DecisionState.SkipDeviceEsp"/>
/// facts. Set-once semantics: later signals with the same or different payload are no-ops
/// once a fact is present (monotonic, analogous to <c>DeviceSetupEnteredUtc</c>).
/// </summary>
public sealed class EspConfigDetectedHandlerTests
{
[Fact]
public void EspConfigDetected_populates_SkipUserEsp_and_SkipDeviceEsp_from_payload()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var step = engine.Reduce(state, MakeSignal(
ordinal: 3,
skipUser: "true",
skipDevice: "false"));
Assert.NotNull(step.NewState.SkipUserEsp);
Assert.True(step.NewState.SkipUserEsp!.Value);
Assert.Equal(3, step.NewState.SkipUserEsp!.SourceSignalOrdinal);
Assert.NotNull(step.NewState.SkipDeviceEsp);
Assert.False(step.NewState.SkipDeviceEsp!.Value);
Assert.Equal(3, step.NewState.SkipDeviceEsp!.SourceSignalOrdinal);
Assert.True(step.Transition.Taken);
Assert.Null(step.Transition.DeadEndReason);
Assert.Equal(nameof(DecisionSignalKind.EspConfigDetected), step.Transition.Trigger);
Assert.Empty(step.Effects);
}
[Fact]
public void EspConfigDetected_leaves_stage_unchanged()
{
var engine = new DecisionEngine();
var midFlight = DecisionState.CreateInitial("s", "t")
.ToBuilder()
.WithStage(SessionStage.EspDeviceSetup)
.WithStepIndex(5)
.WithLastAppliedSignalOrdinal(4)
.Build();
var step = engine.Reduce(midFlight, MakeSignal(
ordinal: 5,
skipUser: "false",
skipDevice: "false"));
Assert.Equal(SessionStage.EspDeviceSetup, step.NewState.Stage);
Assert.Equal(6, step.NewState.StepIndex);
Assert.Equal(5, step.NewState.LastAppliedSignalOrdinal);
}
[Fact]
public void EspConfigDetected_is_setonce_laterSignalDoesNotOverwrite()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var step1 = engine.Reduce(state, MakeSignal(
ordinal: 1,
skipUser: "false",
skipDevice: "false"));
// Second signal flips both values — must be ignored.
var step2 = engine.Reduce(step1.NewState, MakeSignal(
ordinal: 2,
skipUser: "true",
skipDevice: "true"));
Assert.False(step2.NewState.SkipUserEsp!.Value);
Assert.Equal(1, step2.NewState.SkipUserEsp!.SourceSignalOrdinal);
Assert.False(step2.NewState.SkipDeviceEsp!.Value);
Assert.Equal(1, step2.NewState.SkipDeviceEsp!.SourceSignalOrdinal);
// The second signal still bumps bookkeeping (taken transition, no effects).
Assert.True(step2.Transition.Taken);
Assert.Equal(2, step2.NewState.StepIndex);
Assert.Equal(2, step2.NewState.LastAppliedSignalOrdinal);
}
[Fact]
public void EspConfigDetected_missingKeys_leavesFactsNull()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var step = engine.Reduce(state, MakeSignal(
ordinal: 1,
skipUser: null,
skipDevice: null));
Assert.Null(step.NewState.SkipUserEsp);
Assert.Null(step.NewState.SkipDeviceEsp);
Assert.True(step.Transition.Taken);
Assert.Equal(1, step.NewState.StepIndex);
}
[Fact]
public void EspConfigDetected_partialPayload_setsOnlyKnownFact_leavesOtherNull()
{
// Realistic: registry has SkipUserStatusPage but SkipDeviceStatusPage key missing.
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var step = engine.Reduce(state, MakeSignal(
ordinal: 1,
skipUser: "true",
skipDevice: null));
Assert.NotNull(step.NewState.SkipUserEsp);
Assert.True(step.NewState.SkipUserEsp!.Value);
Assert.Null(step.NewState.SkipDeviceEsp);
}
[Fact]
public void EspConfigDetected_partialFirstSignal_secondSignalCanFillMissingFact()
{
// First signal sets only skipUser; second signal (different ordinal) can still fill
// in skipDevice because set-once is per-fact, not per-signal.
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var step1 = engine.Reduce(state, MakeSignal(
ordinal: 1,
skipUser: "true",
skipDevice: null));
var step2 = engine.Reduce(step1.NewState, MakeSignal(
ordinal: 2,
skipUser: null,
skipDevice: "false"));
Assert.NotNull(step2.NewState.SkipUserEsp);
Assert.True(step2.NewState.SkipUserEsp!.Value);
Assert.Equal(1, step2.NewState.SkipUserEsp!.SourceSignalOrdinal);
Assert.NotNull(step2.NewState.SkipDeviceEsp);
Assert.False(step2.NewState.SkipDeviceEsp!.Value);
Assert.Equal(2, step2.NewState.SkipDeviceEsp!.SourceSignalOrdinal);
}
[Fact]
public void EspConfigDetected_schemaVersion2_fallsThroughAsUnhandled()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var step = engine.Reduce(state, MakeSignal(
ordinal: 1,
skipUser: "true",
skipDevice: "false",
schemaVersion: 2));
Assert.False(step.Transition.Taken);
Assert.Equal("unhandled_signal_kind:EspConfigDetected:v2", step.Transition.DeadEndReason);
}
[Fact]
public void EspConfigDetected_stateRoundtripsThroughSerializer()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var populated = engine.Reduce(state, MakeSignal(
ordinal: 7,
skipUser: "true",
skipDevice: "false")).NewState;
var json = StateSerializer.Serialize(populated);
var roundtripped = StateSerializer.Deserialize(json);
Assert.NotNull(roundtripped.SkipUserEsp);
Assert.True(roundtripped.SkipUserEsp!.Value);
Assert.Equal(7, roundtripped.SkipUserEsp!.SourceSignalOrdinal);
Assert.NotNull(roundtripped.SkipDeviceEsp);
Assert.False(roundtripped.SkipDeviceEsp!.Value);
Assert.Equal(7, roundtripped.SkipDeviceEsp!.SourceSignalOrdinal);
}
[Fact]
public void EspConfigDetected_initialState_facts_areNull()
{
var state = DecisionState.CreateInitial("s", "t");
Assert.Null(state.SkipUserEsp);
Assert.Null(state.SkipDeviceEsp);
}
private static DecisionSignal MakeSignal(
long ordinal,
string? skipUser,
string? skipDevice,
int schemaVersion = 1)
{
var payload = new Dictionary<string, string>(StringComparer.Ordinal);
if (skipUser != null) payload[SignalPayloadKeys.SkipUserEsp] = skipUser;
if (skipDevice != null) payload[SignalPayloadKeys.SkipDeviceEsp] = skipDevice;
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: DecisionSignalKind.EspConfigDetected,
kindSchemaVersion: schemaVersion,
occurredAtUtc: new DateTime(2026, 4, 23, 18, 53, 21, DateTimeKind.Utc),
sourceOrigin: "DeviceInfoCollector",
evidence: new Evidence(
kind: EvidenceKind.Raw,
identifier: "esp_config_detected",
summary: $"SkipUser={skipUser ?? "unknown"}, SkipDevice={skipDevice ?? "unknown"}"),
payload: payload);
}
}
}
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Plan §6 Fix 10 — defensive: if a premature <c>EspPhaseChanged(FinalizingSetup)</c>
/// moved the stage to <see cref="SessionStage.AwaitingHello"/> before AccountSetup had
/// been observed, and HelloSafety was armed from that wrong baseline, a subsequent
/// <c>EspPhaseChanged(AccountSetup)</c> must cancel the stale deadline so it cannot fire
/// from the wrong window.
/// <para>
/// Mirrors the pathology observed in session <c>30410cd7</c>: HelloSafety armed at
/// 18:57:45 (Device-ESP exit synthesized as Finalizing) and was never cancelled when the
/// stage bounced back to EspAccountSetup at 18:57:55 — recovery only came from Hello
/// resolving at 19:03:38, 7m13s after arm vs. a 5-min window. Under slower conditions the
/// deadline would have fired first and mis-terminated the session.
/// </para>
/// </summary>
public sealed class HelloSafetyBounceCancelTests
{
private static readonly DateTime Fixed = new DateTime(2026, 4, 23, 18, 57, 55, DateTimeKind.Utc);
[Fact]
public void EspPhaseChanged_AccountSetup_whileInAwaitingHello_cancelsHelloSafetyDeadline()
{
var engine = new DecisionEngine();
// Starting state: stage AwaitingHello with HelloSafety armed (simulates the pre-fix
// premature transition from Device-ESP Finalizing synthesis at 18:57:45).
var helloSafetyDeadline = new ActiveDeadline(
name: DeadlineNames.HelloSafety,
dueAtUtc: Fixed.AddSeconds(-10).AddMinutes(5), // 5 min after the early arm
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
});
var seed = DecisionState.CreateInitial("s", "t")
.ToBuilder()
.WithStage(SessionStage.AwaitingHello)
.WithStepIndex(3)
.WithLastAppliedSignalOrdinal(2)
.AddDeadline(helloSafetyDeadline)
.Build();
// Legitimate AccountSetup signal arrives (the bounce-back).
var signal = new DecisionSignal(
sessionSignalOrdinal: 3,
sessionTraceOrdinal: 3,
kind: DecisionSignalKind.EspPhaseChanged,
kindSchemaVersion: 1,
occurredAtUtc: Fixed,
sourceOrigin: "ProvisioningStatusTracker",
evidence: new Evidence(
kind: EvidenceKind.Raw,
identifier: "esp_phase_changed",
summary: "AccountSetup phase reached"),
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EspPhase] = EnrollmentPhase.AccountSetup.ToString(),
});
var step = engine.Reduce(seed, signal);
// Stage bounces back to EspAccountSetup.
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
// The stale HelloSafety deadline must be gone.
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
// And a CancelDeadline effect for HelloSafety must have been emitted so the
// orchestrator's DeadlineScheduler unschedules it.
Assert.Contains(step.Effects, e =>
e.Kind == DecisionEffectKind.CancelDeadline &&
e.CancelDeadlineName == DeadlineNames.HelloSafety);
// Existing DeviceOnlyEspDetection-cancel behavior must continue to fire too.
Assert.Contains(step.Effects, e =>
e.Kind == DecisionEffectKind.CancelDeadline &&
e.CancelDeadlineName == DeadlineNames.DeviceOnlyEspDetection);
}
[Fact]
public void EspPhaseChanged_AccountSetup_fromEspDeviceSetup_doesNotEmitHelloSafetyCancelWhenNoneArmed()
{
// Sanity: Fix 10 must NOT synthesize a spurious HelloSafety cancel when the state
// was never in AwaitingHello and no HelloSafety deadline is armed. Backward compat
// with all existing Classic fixtures.
var engine = new DecisionEngine();
var seed = DecisionState.CreateInitial("s", "t")
.ToBuilder()
.WithStage(SessionStage.EspDeviceSetup)
.WithStepIndex(1)
.WithLastAppliedSignalOrdinal(0)
.Build();
var signal = new DecisionSignal(
sessionSignalOrdinal: 1,
sessionTraceOrdinal: 1,
kind: DecisionSignalKind.EspPhaseChanged,
kindSchemaVersion: 1,
occurredAtUtc: Fixed,
sourceOrigin: "ProvisioningStatusTracker",
evidence: new Evidence(EvidenceKind.Raw, "esp_phase_changed", "AccountSetup"),
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EspPhase] = EnrollmentPhase.AccountSetup.ToString(),
});
var step = engine.Reduce(seed, signal);
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
// Only DeviceOnlyEspDetection cancel effect — no HelloSafety effect (precondition
// not met).
Assert.DoesNotContain(step.Effects, e =>
e.Kind == DecisionEffectKind.CancelDeadline &&
e.CancelDeadlineName == DeadlineNames.HelloSafety);
Assert.Contains(step.Effects, e =>
e.Kind == DecisionEffectKind.CancelDeadline &&
e.CancelDeadlineName == DeadlineNames.DeviceOnlyEspDetection);
}
}
}
@@ -54,6 +54,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>On <c>ImeUserSessionCompleted</c>: matched pattern id.</summary>
public const string ImePatternId = "patternId";
/// <summary>On <c>EspConfigDetected</c>: "true" / "false"; missing → fact is not set.</summary>
public const string SkipUserEsp = "skipUserEsp";
/// <summary>On <c>EspConfigDetected</c>: "true" / "false"; missing → fact is not set.</summary>
public const string SkipDeviceEsp = "skipDeviceEsp";
// --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------
// Mirrors the EnrollmentEvent fields the reducer must reconstruct for the
// EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are
@@ -29,7 +29,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
{
EnrollmentPhase.DeviceSetup => SessionStage.EspDeviceSetup,
EnrollmentPhase.AccountSetup => SessionStage.EspAccountSetup,
EnrollmentPhase.FinalizingSetup => SessionStage.AwaitingHello,
// Plan §6 Fix 8 — Finalizing is a synthetic phase derived from the Shell-Core
// esp_exiting event; on Classic V1 enrollments the first exit is the Device-ESP
// handoff, not the true final. Only promote to AwaitingHello when reaching it is
// legitimate (AccountSetup already observed, OR SkipUser flow). Otherwise keep
// the current stage — the signal is still recorded as a taken transition with
// the FinalizingEnteredUtc fact + CurrentEnrollmentPhase updated below, but the
// HelloSafety deadline is NOT armed.
EnrollmentPhase.FinalizingSetup => ShouldTransitionToAwaitingHello(state)
? SessionStage.AwaitingHello
: state.Stage,
_ => state.Stage,
};
@@ -84,6 +93,19 @@ namespace AutopilotMonitor.DecisionCore.Engine
effectsList.Add(new DecisionEffect(
DecisionEffectKind.CancelDeadline,
cancelDeadlineName: DeadlineNames.DeviceOnlyEspDetection));
// Plan §6 Fix 10 — defensive belt-and-suspenders for the premature-AwaitingHello
// bounce-back case. If Fix 7's tracker guard or Fix 8's reducer guards ever fail
// and the stage reached AwaitingHello before AccountSetup (i.e. an early Finalizing
// synthesis armed HelloSafety from the wrong baseline), cancel the deadline here
// so it cannot fire from the wrong window after we resume EspAccountSetup.
if (state.Stage == SessionStage.AwaitingHello)
{
builder.CancelDeadline(DeadlineNames.HelloSafety);
effectsList.Add(new DecisionEffect(
DecisionEffectKind.CancelDeadline,
cancelDeadlineName: DeadlineNames.HelloSafety));
}
}
var newState = builder.Build();
@@ -100,15 +122,36 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>
/// Handle <see cref="DecisionSignalKind.EspExiting"/>. Records
/// <see cref="DecisionState.EspFinalExitUtc"/>, transitions to
/// <see cref="SessionStage.AwaitingHello"/>, and schedules the Hello-safety deadline
/// so a hang on Hello doesn't strand the session.
/// <see cref="DecisionState.EspFinalExitUtc"/> for observability; transitions to
/// <see cref="SessionStage.AwaitingHello"/> and arms the Hello-safety deadline only
/// when <see cref="ShouldTransitionToAwaitingHello"/> holds (plan §6 Fix 8). On an
/// early / intermediate exit (e.g. the Device-ESP handoff on a Classic V1 enrollment)
/// the fact is still recorded but the stage is unchanged and no deadline is armed.
/// </summary>
private DecisionStep HandleEspExitingV1(DecisionState state, DecisionSignal signal)
{
var nextStep = state.StepIndex + 1;
var dueAtUtc = signal.OccurredAtUtc.Add(s_helloSafetyWindow);
var shouldTransition = ShouldTransitionToAwaitingHello(state);
var builder = state.ToBuilder()
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
builder.EspFinalExitUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
if (!shouldTransition)
{
// Early / intermediate esp_exiting: keep stage, no HelloSafety arm. Observability
// still records EspFinalExitUtc so downstream classifiers see the full picture.
var noopTransition = BuildTakenTransition(
before: state,
signal: signal,
toStage: state.Stage,
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.EspExiting));
return new DecisionStep(builder.Build(), noopTransition, Array.Empty<DecisionEffect>());
}
var dueAtUtc = signal.OccurredAtUtc.Add(s_helloSafetyWindow);
var helloSafety = new ActiveDeadline(
name: DeadlineNames.HelloSafety,
dueAtUtc: dueAtUtc,
@@ -118,14 +161,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
});
var builder = state.ToBuilder()
builder = builder
.WithStage(SessionStage.AwaitingHello)
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.AddDeadline(helloSafety);
builder.EspFinalExitUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
var newState = builder.Build();
var transition = BuildTakenTransition(
before: state,
@@ -139,7 +177,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: helloSafety),
};
return new DecisionStep(newState, transition, effects);
return new DecisionStep(builder.Build(), transition, effects);
}
/// <summary>
@@ -333,6 +333,53 @@ namespace AutopilotMonitor.DecisionCore.Engine
private DecisionStep HandleAutopilotProfileReadV1(DecisionState state, DecisionSignal signal) =>
RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.AutopilotProfileRead));
/// <summary>
/// Handle <see cref="DecisionSignalKind.EspConfigDetected"/>. Plan §6 Fix 9.
/// <para>
/// Populates <see cref="DecisionState.SkipUserEsp"/> / <see cref="DecisionState.SkipDeviceEsp"/>
/// from the payload (<see cref="SignalPayloadKeys.SkipUserEsp"/> /
/// <see cref="SignalPayloadKeys.SkipDeviceEsp"/>). Set-once semantics — later signals with
/// identical payload are no-ops, and keys missing from the payload never clear a fact that
/// was already set (a re-read that failed to pick up one half must not invalidate the
/// other). Stage is unchanged; this is a fact-only signal that Fix 8's reducer guards
/// read.
/// </para>
/// </summary>
private DecisionStep HandleEspConfigDetectedV1(DecisionState state, DecisionSignal signal)
{
var nextStep = state.StepIndex + 1;
var builder = state.ToBuilder()
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
if (signal.Payload != null)
{
if (state.SkipUserEsp == null
&& signal.Payload.TryGetValue(SignalPayloadKeys.SkipUserEsp, out var rawSkipUser)
&& bool.TryParse(rawSkipUser, out var skipUser))
{
builder.SkipUserEsp = new SignalFact<bool>(skipUser, signal.SessionSignalOrdinal);
}
if (state.SkipDeviceEsp == null
&& signal.Payload.TryGetValue(SignalPayloadKeys.SkipDeviceEsp, out var rawSkipDevice)
&& bool.TryParse(rawSkipDevice, out var skipDevice))
{
builder.SkipDeviceEsp = new SignalFact<bool>(skipDevice, signal.SessionSignalOrdinal);
}
}
var newState = builder.Build();
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: state.Stage,
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.EspConfigDetected));
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
}
private DecisionStep RecordDiagnosticObservation(DecisionState state, DecisionSignal signal, string trigger)
{
var newState = BumpStepBookkeeping(state, signal);
@@ -419,6 +466,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(bookkept, transition, Array.Empty<DecisionEffect>());
}
/// <summary>
/// Plan §6 Fix 8 — gate for promoting to <see cref="SessionStage.AwaitingHello"/>
/// on Classic V1 paths. Returns <c>true</c> only when the promotion is legitimate:
/// <list type="bullet">
/// <item>AccountSetup has already been entered (the post-Account-ESP final exit case), or</item>
/// <item>SkipUser is explicitly <c>true</c> (the Account-ESP phase is skipped; first
/// esp_exiting IS the final exit on device-only / full-skip flows).</item>
/// </list>
/// Otherwise returns <c>false</c> — a FinalizingSetup / EspExiting signal arriving
/// before AccountSetup on a non-SkipUser enrollment is either a collector bug or the
/// Device-ESP intermediate exit that Fix 7 otherwise swallows at the tracker layer.
/// Keeping this helper in the reducer means even a regression in Fix 7 cannot drive a
/// premature <c>AwaitingHello</c> + HelloSafety arm.
/// </summary>
private static bool ShouldTransitionToAwaitingHello(DecisionState state)
{
if (state.AccountSetupEnteredUtc != null) return true;
if (state.SkipUserEsp?.Value == true) return true;
return false;
}
/// <summary>
/// Determine the user-visible enrollment phase implied by an ESP phase-change signal.
/// Plan §2.3 phase-fact mapping. Populated in M3.1 as Classic handlers come online.
@@ -92,6 +92,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
// ----- Diagnostic observations (DecisionEngine.Shared.cs) — Plan §4.x M4.4.3 -----
(DecisionSignalKind.DeviceInfoCollected, 1) => HandleDeviceInfoCollectedV1(state, signal),
(DecisionSignalKind.AutopilotProfileRead, 1) => HandleAutopilotProfileReadV1(state, signal),
(DecisionSignalKind.EspConfigDetected, 1) => HandleEspConfigDetectedV1(state, signal),
// ----- Informational pass-through (DecisionEngine.Shared.cs) — single-rail §1.3 -----
(DecisionSignalKind.InformationalEvent, 1) => HandleInformationalEventV1(state, signal),
@@ -26,6 +26,7 @@ namespace AutopilotMonitor.DecisionCore.Signals
SystemRebootObserved,
DeviceInfoCollected,
AutopilotProfileRead,
EspConfigDetected,
// --- Raw — WhiteGlove Part 2 (Post-Reboot User-Sign-In) ---
UserAadSignInComplete,
@@ -46,6 +46,8 @@ namespace AutopilotMonitor.DecisionCore.State
SignalFact<DateTime>? helloResolvedPart2Utc,
SignalFact<DateTime>? desktopArrivedPart2Utc,
SignalFact<DateTime>? accountSetupCompletedPart2Utc,
SignalFact<bool>? skipUserEsp,
SignalFact<bool>? skipDeviceEsp,
IReadOnlyList<ActiveDeadline> deadlines,
long lastAppliedSignalOrdinal,
int stepIndex,
@@ -86,6 +88,8 @@ namespace AutopilotMonitor.DecisionCore.State
HelloResolvedPart2Utc = helloResolvedPart2Utc;
DesktopArrivedPart2Utc = desktopArrivedPart2Utc;
AccountSetupCompletedPart2Utc = accountSetupCompletedPart2Utc;
SkipUserEsp = skipUserEsp;
SkipDeviceEsp = skipDeviceEsp;
Deadlines = deadlines ?? throw new ArgumentNullException(nameof(deadlines));
LastAppliedSignalOrdinal = lastAppliedSignalOrdinal;
StepIndex = stepIndex;
@@ -147,6 +151,13 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; }
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; }
// --- ESP skip-flag facts from EspConfigDetected (plan §6 Fix 9). SkipUser=true means the
// Account-ESP phase never runs on this enrollment, so AwaitingHello is reachable directly
// after Device-ESP. When null the configuration has not been observed yet — guards that
// rely on an explicit bool must treat null as "unknown" rather than "false".
public SignalFact<bool>? SkipUserEsp { get; }
public SignalFact<bool>? SkipDeviceEsp { get; }
public IReadOnlyList<ActiveDeadline> Deadlines { get; }
public long LastAppliedSignalOrdinal { get; }
@@ -193,6 +204,8 @@ namespace AutopilotMonitor.DecisionCore.State
helloResolvedPart2Utc: null,
desktopArrivedPart2Utc: null,
accountSetupCompletedPart2Utc: null,
skipUserEsp: null,
skipDeviceEsp: null,
deadlines: Array.Empty<ActiveDeadline>(),
lastAppliedSignalOrdinal: -1,
stepIndex: 0);
@@ -44,6 +44,8 @@ namespace AutopilotMonitor.DecisionCore.State
HelloResolvedPart2Utc = source.HelloResolvedPart2Utc;
DesktopArrivedPart2Utc = source.DesktopArrivedPart2Utc;
AccountSetupCompletedPart2Utc = source.AccountSetupCompletedPart2Utc;
SkipUserEsp = source.SkipUserEsp;
SkipDeviceEsp = source.SkipDeviceEsp;
Deadlines = new List<ActiveDeadline>(source.Deadlines);
LastAppliedSignalOrdinal = source.LastAppliedSignalOrdinal;
StepIndex = source.StepIndex;
@@ -75,6 +77,8 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact<DateTime>? HelloResolvedPart2Utc { get; set; }
public SignalFact<DateTime>? DesktopArrivedPart2Utc { get; set; }
public SignalFact<DateTime>? AccountSetupCompletedPart2Utc { get; set; }
public SignalFact<bool>? SkipUserEsp { get; set; }
public SignalFact<bool>? SkipDeviceEsp { get; set; }
public List<ActiveDeadline> Deadlines { get; set; }
public long LastAppliedSignalOrdinal { get; set; }
public int StepIndex { get; set; }
@@ -148,6 +152,8 @@ namespace AutopilotMonitor.DecisionCore.State
helloResolvedPart2Utc: HelloResolvedPart2Utc,
desktopArrivedPart2Utc: DesktopArrivedPart2Utc,
accountSetupCompletedPart2Utc: AccountSetupCompletedPart2Utc,
skipUserEsp: SkipUserEsp,
skipDeviceEsp: SkipDeviceEsp,
deadlines: Deadlines.ToArray(),
lastAppliedSignalOrdinal: LastAppliedSignalOrdinal,
stepIndex: StepIndex,