fix(agent/v2,decisioncore): gate AwaitingHello on AccountSetup truly succeeded

Session 330f73f3 completed via DeadlineFired:finalizing_grace (helloOutcome=
Timeout) while ESP AccountSetup was still at "4 of 5 subcategories completed"
and 3 of 8 apps still pending. ShouldTransitionToAwaitingHello promoted on
AccountSetupEnteredUtc != null alone, but Windows CXH event 62407 fires at
every ESP-page transition — the first occurrence (Device→Account handoff,
9 s after AccountSetup entered) was treated as the genuine final exit,
armed HelloSafety against an in-progress AccountSetup, fired its 5-min
synthetic timeout, and FinalizingGrace declared the session terminal
mid-flight.

Introduce a strong "AccountSetup truly succeeded" fact sourced from
ProvisioningStatusTracker (categorySucceeded=true OR fallback after 30 s
of all subcategories at succeeded/notRequired — same shape as DeviceSetup).
Forwarded by EspAndHelloTracker, posted as new DecisionSignalKind.
AccountSetupProvisioningComplete by the V2 adapter, lands as DecisionState.
AccountSetupProvisioningSucceededUtc. The reducer's
ShouldTransitionToAwaitingHello now requires this fact (or SkipUserEsp);
the old AccountSetupEnteredUtc gate becomes an entry-time marker only.

HandleAccountSetupProvisioningCompleteV1 has a deferred-promotion path:
when the strong fact arrives AFTER a guard-blocked EspExiting
(EspFinalExitUtc set) OR after a guard-blocked EspPhaseChanged(Finalizing-
Setup) (FinalizingEnteredUtc set, ShellCoreTracker adapter fire-once-
dedupes), the handler performs the deferred promotion + arms HelloSafety
anchored at the AccountSetup-succeeded instant.

The new fact is registered in DecisionStateSignalCensus so enrollment_*
audit trails and final-status.json show whether the strong gate was
satisfied at termination — load-bearing for diagnosing future
false-negative timeouts under the new gate.

Side cleanup: ProvisioningStatusTracker's fallback timer callbacks (new
AccountSetup AND pre-existing DeviceSetup parallel path) now reset the
timer field on every abort path AND in the outer exception catch. Without
this, a transient subcategory flicker between fallback arm and timer fire
could permanently prevent re-arming even when the state re-stabilises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-18 11:46:37 +02:00
parent 9538eba3ad
commit bb4f663ace
19 changed files with 753 additions and 47 deletions
@@ -87,7 +87,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
var espExiting = ingress.Posted.FirstOrDefault(p => p.Kind == DecisionSignalKind.EspExiting);
Assert.NotNull(espExiting);
// Build a Classic state already at AccountSetup so the EspExiting guard unlocks.
// Build a Classic state already at AccountSetup AND with the strong post-AccountSetup
// gate satisfied (session 330f73f3 fix) so the EspExiting guard unlocks. Pre-fix this
// worked with only AccountSetupEnteredUtc; the new gate requires AccountSetupProvisioningSucceededUtc.
var engine = new DecisionEngine();
var seed = DecisionState.CreateInitial("S1", "T1", agentBootUtc: sourceTime.AddMinutes(-5))
.ToBuilder()
@@ -95,6 +97,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
.WithStepIndex(2)
.WithLastAppliedSignalOrdinal(1);
seed.AccountSetupEnteredUtc = new SignalFact<DateTime>(sourceTime.AddMinutes(-2), 1);
seed.AccountSetupProvisioningSucceededUtc = new SignalFact<DateTime>(sourceTime.AddMinutes(-1).AddSeconds(-30), 1);
var state = seed.Build();
// Re-wrap the adapter-posted signal with the ordinal the engine expects.
@@ -220,6 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
.WithStepIndex(2)
.WithLastAppliedSignalOrdinal(1);
seed.AccountSetupEnteredUtc = new SignalFact<DateTime>(sourceTime.AddMinutes(-2), 1);
seed.AccountSetupProvisioningSucceededUtc = new SignalFact<DateTime>(sourceTime.AddMinutes(-1).AddSeconds(-30), 1);
var state = seed.Build();
var enriched = new DecisionSignal(
@@ -80,6 +80,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// </summary>
public event EventHandler DeviceSetupProvisioningComplete;
/// <summary>
/// Session 330f73f3 fix: fired when AccountSetup provisioning resolves with success
/// (or the fallback confirmed). Forwarded from <see cref="ProvisioningStatusTracker"/>;
/// the V2 adapter posts it as <c>DecisionSignalKind.AccountSetupProvisioningComplete</c>
/// so the reducer's <c>ShouldTransitionToAwaitingHello</c> gate has the strong
/// post-AccountSetup fact and stops promoting on intermediate Shell-Core 62407 events.
/// </summary>
public event EventHandler AccountSetupProvisioningComplete;
// PR4 (882fef64 debrief) — coordinator-forwarded copy of <see cref="HelloTracker.HelloPolicyDetected"/>.
// Args: (helloEnabled, source). Subscribed by EspAndHelloTrackerAdapter to post a
// DecisionSignalKind.HelloPolicyDetected signal.
@@ -250,6 +259,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_logger);
_provisioningTracker.EspFailureDetected += OnEspFailureDetected;
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
_provisioningTracker.AccountSetupProvisioningComplete += OnAccountSetupProvisioningComplete;
_provisioningTracker.Start();
if (_modernDeploymentWatcherEnabled)
@@ -315,6 +325,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
_provisioningTracker.EspFailureDetected -= OnEspFailureDetected;
_provisioningTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete;
_provisioningTracker.AccountSetupProvisioningComplete -= OnAccountSetupProvisioningComplete;
_provisioningTracker.Stop("tracker_stopped");
}
catch (Exception ex) { _logger.Error("Error stopping provisioning status tracker", ex); }
@@ -471,6 +482,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
catch (Exception ex) { _logger.Error("Error forwarding DeviceSetupProvisioningComplete", ex); }
}
private void OnAccountSetupProvisioningComplete(object sender, EventArgs e)
{
// Mirror of DeviceSetup path. Adapter falls back to clock for the signal timestamp.
LastEventOccurredAtUtc = null;
try { AccountSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("Error forwarding AccountSetupProvisioningComplete", ex); }
}
private void OnEspExited(object sender, EspExitedEventArgs args)
{
// EspExited carries its source-event timestamp on the args (set by ShellCoreTracker
@@ -40,6 +40,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
internal const string ProvisioningStatusRegistryPath = @"SOFTWARE\Microsoft\Provisioning\AutopilotSettings";
internal const int DeviceSetupFallbackDelaySeconds = 30;
// Same fallback window as DeviceSetup, applied to AccountSetup. Session 330f73f3 fix:
// some Windows builds never set AccountSetupCategory.Status.categorySucceeded even when
// all subcategories resolve to succeeded/notRequired; without a fallback the strong
// post-AccountSetup gate (AccountSetupProvisioningSucceededUtc) would never be set and
// the reducer would never promote to AwaitingHello.
internal const int AccountSetupFallbackDelaySeconds = 30;
internal const int ProvisioningDebounceMilliseconds = 1000;
private static readonly string[] ProvisioningCategoryNames =
@@ -58,6 +64,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private Timer _provisioningWatcherRetryTimer;
private Timer _provisioningDebounceTimer;
private Timer _deviceSetupFallbackTimer;
private Timer _accountSetupFallbackTimer;
// Track the raw JSON per category — used to detect any changes at all
private Dictionary<string, string> _lastProvisioningJson;
@@ -73,6 +80,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
private Dictionary<string, Dictionary<string, string>> _lastSubcategoryStates;
// Fire-once guard for DeviceSetupProvisioningComplete event
private bool _deviceSetupProvisioningCompleteFired;
// Fire-once guard for AccountSetupProvisioningComplete event (session 330f73f3 fix)
private bool _accountSetupProvisioningCompleteFired;
// WhiteGlove confirmation: DeviceSetup registry contains SaveWhiteGloveSuccessResult=succeeded
private bool _saveWhiteGloveSuccessResultSeen;
// Track SaveWhiteGloveSuccessResult state transitions for observability (null → notStarted → succeeded)
@@ -81,6 +90,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
public event EventHandler<string> EspFailureDetected;
public event EventHandler DeviceSetupProvisioningComplete;
/// <summary>
/// Fires once when <c>AccountSetupCategory.Status</c> resolves to
/// <c>categorySucceeded=true</c>, or when the fallback fires (all subcategories
/// succeeded/notRequired but Windows never set the boolean — same fallback shape as
/// DeviceSetup). Session 330f73f3 fix: this is the strong "User-ESP truly finished"
/// signal that gates the reducer's promotion to <c>AwaitingHello</c>; without it the
/// reducer ignores intermediate Shell-Core 62407 events.
/// </summary>
public event EventHandler AccountSetupProvisioningComplete;
public ProvisioningStatusTracker(
string sessionId,
@@ -219,6 +237,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_lastSubcategoryStates = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
_deviceSetupProvisioningCompleteFired = false;
_deviceSetupFallbackTimer = null;
_accountSetupProvisioningCompleteFired = false;
_accountSetupFallbackTimer = null;
// Try to start immediately; if key doesn't exist yet, retry every 2s
if (!TryStartWatcher())
@@ -252,6 +272,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_deviceSetupFallbackTimer?.Dispose();
_deviceSetupFallbackTimer = null;
_accountSetupFallbackTimer?.Dispose();
_accountSetupFallbackTimer = null;
if (_provisioningWatcher != null)
{
_provisioningWatcher.Dispose();
@@ -418,6 +441,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
TimeSpan.FromSeconds(DeviceSetupFallbackDelaySeconds),
Timeout.InfiniteTimeSpan);
}
// Session 330f73f3 fix: same flow for AccountSetup. Fires
// AccountSetupProvisioningComplete when the category resolves with
// success, or when the fallback confirms all subcategories
// succeeded/notRequired but categorySucceeded was never set.
if (!_accountSetupProvisioningCompleteFired
&& _lastCategorySucceeded.TryGetValue("AccountSetupCategory.Status", out var asSucceeded)
&& asSucceeded == true)
{
_accountSetupProvisioningCompleteFired = true;
_logger.Info("Provisioning status: AccountSetup succeeded — firing AccountSetupProvisioningComplete");
try { AccountSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("AccountSetupProvisioningComplete handler failed", ex); }
}
if (!_accountSetupProvisioningCompleteFired
&& _accountSetupFallbackTimer == null
&& _lastCategorySucceeded.TryGetValue("AccountSetupCategory.Status", out var asFallbackState)
&& asFallbackState == null
&& _lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var asFallbackSubStates)
&& asFallbackSubStates.Count > 0
&& asFallbackSubStates.Values.All(s =>
string.Equals(s, "succeeded", StringComparison.OrdinalIgnoreCase) ||
string.Equals(s, "notRequired", StringComparison.OrdinalIgnoreCase)))
{
_logger.Info($"Provisioning status: AccountSetup — all {asFallbackSubStates.Count} subcategories succeeded " +
$"but categorySucceeded not set by Windows — starting {AccountSetupFallbackDelaySeconds}s fallback timer");
_accountSetupFallbackTimer = new Timer(
OnAccountSetupFallbackTimerExpired,
null,
TimeSpan.FromSeconds(AccountSetupFallbackDelaySeconds),
Timeout.InfiniteTimeSpan);
}
}
}
}
@@ -427,6 +483,189 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
/// <summary>
/// Session 330f73f3 fix: AccountSetup-side mirror of <see cref="OnDeviceSetupFallbackTimerExpired"/>.
/// Confirms via a fresh registry read that all subcategories are still
/// succeeded/notRequired and categorySucceeded is still unset, then fires
/// <see cref="AccountSetupProvisioningComplete"/>. If Windows wrote a real
/// categorySucceeded value in the meantime, the normal path re-runs and handles it.
/// <para>
/// On every abort path the timer field is reset to <c>null</c> so a later
/// <c>CheckProvisioningStatus</c> can re-arm the fallback when the "all subcategories
/// succeeded" condition is re-satisfied (transient flicker race: a subcategory briefly
/// drops to in_progress between fallback arm and timer fire, the callback aborts —
/// without resetting the field, a re-stabilised state could never re-arm and the
/// strong-gate fact never gets posted).
/// </para>
/// </summary>
private void OnAccountSetupFallbackTimerExpired(object state)
{
try
{
lock (_stateLock)
{
if (_accountSetupProvisioningCompleteFired)
{
_logger.Debug("AccountSetup fallback timer expired but AccountSetupProvisioningComplete already fired — ignoring");
// No timer-field reset needed: re-arm is gated on _accountSetupProvisioningCompleteFired too.
return;
}
string registryJson = null;
using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath, writable: false))
{
registryJson = key?.GetValue("AccountSetupCategory.Status")?.ToString();
}
if (string.IsNullOrEmpty(registryJson))
{
_logger.Warning("AccountSetup fallback timer: registry value not found — aborting fallback");
ResetAccountSetupFallbackTimer();
return;
}
bool? categorySucceeded = null;
List<SubcategoryInfo> subcategories = null;
try
{
using (var doc = JsonDocument.Parse(registryJson))
{
categorySucceeded = SafeGetBool(doc.RootElement, "categorySucceeded");
subcategories = ParseSubcategories(doc.RootElement);
}
}
catch (JsonException ex)
{
_logger.Warning($"AccountSetup fallback timer: failed to parse registry JSON — aborting fallback: {ex.Message}");
ResetAccountSetupFallbackTimer();
return;
}
if (categorySucceeded.HasValue)
{
_logger.Info($"AccountSetup fallback timer: categorySucceeded is now {categorySucceeded.Value} — normal path will handle this");
ResetAccountSetupFallbackTimer();
CheckProvisioningStatus();
return;
}
if (subcategories == null || subcategories.Count == 0)
{
_logger.Warning("AccountSetup fallback timer: no subcategories found — aborting fallback");
ResetAccountSetupFallbackTimer();
return;
}
var nonSucceeded = subcategories.Where(s =>
!string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)).ToList();
if (nonSucceeded.Count > 0)
{
_logger.Warning($"AccountSetup fallback timer: {nonSucceeded.Count} subcategory/ies not succeeded " +
$"({string.Join(", ", nonSucceeded.Select(s => $"{s.Name}={s.State}"))}) — aborting fallback");
ResetAccountSetupFallbackTimer();
return;
}
EmitRawRegistryDump("AccountSetup", registryJson, "fallback_confirmed");
_logger.Warning($"AccountSetup fallback: all {subcategories.Count} subcategories succeeded but " +
$"categorySucceeded was not set by Windows after {AccountSetupFallbackDelaySeconds}s — treating as complete");
var subcatData = new Dictionary<string, object>();
foreach (var sub in subcategories)
{
subcatData[sub.Name] = new Dictionary<string, string>
{
{ "state", sub.State },
{ "statusText", sub.StatusText }
};
}
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = Constants.EventTypes.EspProvisioningStatus,
Severity = EventSeverity.Warning,
Source = "EspAndHelloTracker",
Phase = EnrollmentPhase.Unknown,
Message = $"ESP provisioning status: AccountSetup — all {subcategories.Count} subcategories succeeded " +
$"but categorySucceeded was not confirmed by Windows — treating as complete (fallback after {AccountSetupFallbackDelaySeconds}s)",
Data = new Dictionary<string, object>
{
{ "category", "AccountSetup" },
{ "categorySucceeded", "in_progress" },
{ "fallbackApplied", true },
{ "fallbackReason", "all_subcategories_succeeded_category_unresolved" },
{ "fallbackDelaySeconds", AccountSetupFallbackDelaySeconds },
{ "subcategoryCount", subcategories.Count },
{ "subcategories", subcatData }
}
});
// Fired success path: dispose + null the timer so Stop() doesn't redundantly
// dispose it. Re-arm is also gated on _accountSetupProvisioningCompleteFired so
// null-ing here is correctness-neutral on the start-condition side.
ResetAccountSetupFallbackTimer();
_accountSetupProvisioningCompleteFired = true;
try { AccountSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("AccountSetupProvisioningComplete handler failed (fallback path)", ex); }
}
}
catch (Exception ex)
{
_logger.Error($"AccountSetup fallback timer callback failed: {ex.Message}", ex);
// Defensive re-arm-blocker cleanup. If the inner block threw before any of the
// explicit Reset paths ran (e.g. registry open / Emit / Invoke failure), the
// timer field would otherwise stay non-null and CheckProvisioningStatus' start
// condition could never re-arm a re-stabilised "all subcategories succeeded"
// state. The lock is re-entrant — safe to re-acquire here.
try
{
lock (_stateLock)
{
ResetAccountSetupFallbackTimer();
}
}
catch (Exception cleanupEx)
{
_logger.Error("AccountSetup fallback timer reset on exception path failed", cleanupEx);
}
}
}
/// <summary>
/// Reset the AccountSetup fallback timer field after the callback consumes it. Required
/// because the start condition in <see cref="CheckProvisioningStatus"/> gates re-arming
/// on <c>_accountSetupFallbackTimer == null</c>; without this reset a transient flicker
/// (subcategory briefly drops to in_progress between arm and timer fire → callback
/// aborts) would permanently prevent re-arming even when the state re-stabilises.
/// Caller already holds <see cref="_stateLock"/>.
/// </summary>
private void ResetAccountSetupFallbackTimer()
{
_accountSetupFallbackTimer?.Dispose();
_accountSetupFallbackTimer = null;
}
/// <summary>
/// DeviceSetup mirror of <see cref="ResetAccountSetupFallbackTimer"/>. Same re-arm-blocker
/// concern, same fix. Caller already holds <see cref="_stateLock"/>.
/// </summary>
private void ResetDeviceSetupFallbackTimer()
{
_deviceSetupFallbackTimer?.Dispose();
_deviceSetupFallbackTimer = null;
}
/// <summary>
/// Parity with <see cref="OnAccountSetupFallbackTimerExpired"/> — every abort path
/// resets <see cref="_deviceSetupFallbackTimer"/> to <c>null</c> so a transient
/// subcategory flicker that aborts the callback cannot permanently block a re-arm
/// from <see cref="CheckProvisioningStatus"/>.
/// </summary>
private void OnDeviceSetupFallbackTimerExpired(object state)
{
try
@@ -436,6 +675,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (_deviceSetupProvisioningCompleteFired)
{
_logger.Debug("DeviceSetup fallback timer expired but DeviceSetupProvisioningComplete already fired — ignoring");
// No timer-field reset needed: re-arm is gated on _deviceSetupProvisioningCompleteFired too.
return;
}
@@ -448,6 +688,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (string.IsNullOrEmpty(registryJson))
{
_logger.Warning("DeviceSetup fallback timer: registry value not found — aborting fallback");
ResetDeviceSetupFallbackTimer();
return;
}
@@ -464,12 +705,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
catch (JsonException ex)
{
_logger.Warning($"DeviceSetup fallback timer: failed to parse registry JSON — aborting fallback: {ex.Message}");
ResetDeviceSetupFallbackTimer();
return;
}
if (categorySucceeded.HasValue)
{
_logger.Info($"DeviceSetup fallback timer: categorySucceeded is now {categorySucceeded.Value} — normal path will handle this");
ResetDeviceSetupFallbackTimer();
CheckProvisioningStatus();
return;
}
@@ -477,6 +720,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (subcategories == null || subcategories.Count == 0)
{
_logger.Warning("DeviceSetup fallback timer: no subcategories found — aborting fallback");
ResetDeviceSetupFallbackTimer();
return;
}
@@ -488,6 +732,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
_logger.Warning($"DeviceSetup fallback timer: {nonSucceeded.Count} subcategory/ies not succeeded " +
$"({string.Join(", ", nonSucceeded.Select(s => $"{s.Name}={s.State}"))}) — aborting fallback");
ResetDeviceSetupFallbackTimer();
return;
}
@@ -528,6 +773,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
});
// Fired success path: dispose + null the timer so Stop() doesn't redundantly
// dispose it. Re-arm is also gated on _deviceSetupProvisioningCompleteFired so
// null-ing here is correctness-neutral on the start-condition side.
ResetDeviceSetupFallbackTimer();
_deviceSetupProvisioningCompleteFired = true;
try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
catch (Exception ex) { _logger.Error("DeviceSetupProvisioningComplete handler failed (fallback path)", ex); }
@@ -536,6 +785,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
catch (Exception ex)
{
_logger.Error($"DeviceSetup fallback timer callback failed: {ex.Message}", ex);
// Mirror of AccountSetup defensive cleanup: if the inner block threw before any
// of the explicit Reset paths ran, the timer field would otherwise stay non-null
// and CheckProvisioningStatus' start condition could never re-arm. Lock is
// re-entrant — safe to re-acquire.
try
{
lock (_stateLock)
{
ResetDeviceSetupFallbackTimer();
}
}
catch (Exception cleanupEx)
{
_logger.Error("DeviceSetup fallback timer reset on exception path failed", cleanupEx);
}
}
}
@@ -26,6 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
/// <item><c>WhiteGloveCompleted</c> → <see cref="DecisionSignalKind.WhiteGloveShellCoreSuccess"/></item>
/// <item><c>EspFailureDetected</c> → <see cref="DecisionSignalKind.EspTerminalFailure"/> (merged aus ShellCore + Provisioning)</item>
/// <item><c>DeviceSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/></item>
/// <item><c>AccountSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/> (session 330f73f3 fix — strong AccountSetup-done gate)</item>
/// <item><c>EspExited</c> → <see cref="DecisionSignalKind.EspExiting"/> (no dedup — every 62407 forwarded; reducer guard decides)</item>
/// </list>
/// </para>
@@ -50,6 +51,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private bool _whiteGloveSuccessPosted;
private bool _espFailurePosted;
private bool _deviceSetupCompletePosted;
private bool _accountSetupCompletePosted;
private bool _helloPolicyPosted;
/// <summary>Tracked HelloOutcome (read via event; coordinator exposes property).</summary>
@@ -67,6 +69,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_coordinator.WhiteGloveCompleted += OnWhiteGloveCompleted;
_coordinator.EspFailureDetected += OnEspFailure;
_coordinator.DeviceSetupProvisioningComplete += OnDeviceSetupComplete;
_coordinator.AccountSetupProvisioningComplete += OnAccountSetupComplete;
_coordinator.HelloPolicyDetected += OnHelloPolicyDetected;
_coordinator.EspExited += OnEspExited;
}
@@ -78,6 +81,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_coordinator.WhiteGloveCompleted -= OnWhiteGloveCompleted;
_coordinator.EspFailureDetected -= OnEspFailure;
_coordinator.DeviceSetupProvisioningComplete -= OnDeviceSetupComplete;
_coordinator.AccountSetupProvisioningComplete -= OnAccountSetupComplete;
_coordinator.HelloPolicyDetected -= OnHelloPolicyDetected;
_coordinator.EspExited -= OnEspExited;
}
@@ -87,6 +91,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private void OnWhiteGloveCompleted(object sender, EventArgs e) => EmitWhiteGlove();
private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType);
private void OnDeviceSetupComplete(object sender, EventArgs e) => EmitDeviceSetupComplete();
private void OnAccountSetupComplete(object sender, EventArgs e) => EmitAccountSetupComplete();
private void OnHelloPolicyDetected(bool helloEnabled, string source) => EmitHelloPolicy(helloEnabled, source);
private void OnEspExited(object sender, EspExitedEventArgs args) => EmitEspExiting();
@@ -95,6 +100,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
internal void TriggerWhiteGloveFromTest() => EmitWhiteGlove();
internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType);
internal void TriggerDeviceSetupCompleteFromTest() => EmitDeviceSetupComplete();
internal void TriggerAccountSetupCompleteFromTest() => EmitAccountSetupComplete();
internal void TriggerHelloPolicyDetectedFromTest(bool helloEnabled, string source) => EmitHelloPolicy(helloEnabled, source);
internal void TriggerEspExitingFromTest() => EmitEspExiting();
@@ -349,5 +355,44 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
["deviceSetupResolved"] = deviceSetupResolved,
});
}
/// <summary>
/// Session 330f73f3 fix: emits the strong post-AccountSetup gate signal. Coordinator
/// already deduped at the tracker layer; this method's own flag is a second-line
/// defense against duplicate Subscribe calls.
/// </summary>
private void EmitAccountSetupComplete()
{
if (_accountSetupCompletePosted) return;
_accountSetupCompletePosted = true;
var snapshot = _coordinator.GetProvisioningCategorySnapshot();
var accountSetupResolved =
snapshot.TryGetValue("AccountSetupCategory.Status", out var asState) && asState.HasValue
? asState.Value.ToString().ToLowerInvariant()
: "unknown";
var now = ResolveOccurredAt(out var derivedFromClock);
var derivationInputs = new Dictionary<string, string>(StringComparer.Ordinal)
{
["subSource"] = "ProvisioningStatusTracker",
["accountSetupResolved"] = accountSetupResolved,
};
TagDerivedTimestamp(derivationInputs, derivedFromClock);
_ingress.Post(
kind: DecisionSignalKind.AccountSetupProvisioningComplete,
occurredAtUtc: now,
sourceOrigin: SourceOrigin,
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: DetectorId,
summary: "AccountSetupCategory provisioning completed (coordinator-forwarded)",
derivationInputs: derivationInputs),
payload: new Dictionary<string, string>(StringComparer.Ordinal)
{
["accountSetupResolved"] = accountSetupResolved,
});
}
}
}
@@ -9,12 +9,13 @@ using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
/// <summary>
/// Adapter for <see cref="ProvisioningStatusTracker"/> → 2 DecisionSignalKinds.
/// Adapter for <see cref="ProvisioningStatusTracker"/> → 3 DecisionSignalKinds.
/// Plan §2.1a / §2.2.
/// <para>
/// Event mapping:
/// <list type="bullet">
/// <item><c>DeviceSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/></item>
/// <item><c>AccountSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/> (session 330f73f3 fix)</item>
/// <item><c>EspFailureDetected</c> → <see cref="DecisionSignalKind.EspTerminalFailure"/></item>
/// </list>
/// </para>
@@ -32,6 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly IClock _clock;
private bool _deviceSetupCompletePosted;
private bool _accountSetupCompletePosted;
private bool _espFailurePosted;
public ProvisioningStatusTrackerAdapter(
@@ -44,19 +46,23 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_tracker.DeviceSetupProvisioningComplete += OnDeviceSetupComplete;
_tracker.AccountSetupProvisioningComplete += OnAccountSetupComplete;
_tracker.EspFailureDetected += OnEspFailure;
}
public void Dispose()
{
_tracker.DeviceSetupProvisioningComplete -= OnDeviceSetupComplete;
_tracker.AccountSetupProvisioningComplete -= OnAccountSetupComplete;
_tracker.EspFailureDetected -= OnEspFailure;
}
private void OnDeviceSetupComplete(object sender, EventArgs e) => EmitDeviceSetupComplete();
private void OnAccountSetupComplete(object sender, EventArgs e) => EmitAccountSetupComplete();
private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType);
internal void TriggerDeviceSetupCompleteFromTest() => EmitDeviceSetupComplete();
internal void TriggerAccountSetupCompleteFromTest() => EmitAccountSetupComplete();
internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType);
private void EmitDeviceSetupComplete()
@@ -88,6 +94,35 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
});
}
private void EmitAccountSetupComplete()
{
if (_accountSetupCompletePosted) return;
_accountSetupCompletePosted = true;
var snapshot = _tracker.GetProvisioningCategorySnapshot();
var accountSetupResolved = snapshot.TryGetValue("AccountSetupCategory.Status", out var asState) && asState.HasValue
? asState.Value.ToString().ToLowerInvariant()
: "unknown";
_ingress.Post(
kind: DecisionSignalKind.AccountSetupProvisioningComplete,
occurredAtUtc: _clock.UtcNow,
sourceOrigin: "ProvisioningStatusTracker",
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "provisioning-status-tracker-v1",
summary: "AccountSetupCategory provisioning completed",
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
{
["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\AccountSetupCategory.Status",
["accountSetupResolved"] = accountSetupResolved,
}),
payload: new Dictionary<string, string>(StringComparer.Ordinal)
{
["accountSetupResolved"] = accountSetupResolved,
});
}
private void EmitEspFailure(string failureType)
{
if (_espFailurePosted) return;
@@ -1 +1 @@
756
768
@@ -1 +1 @@
1350
1353
@@ -42,9 +42,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
[Fact]
public void HelloSafety_armed_from_replayed_EspExiting_floors_at_AgentBootUtc()
{
// Setup: state already in EspAccountSetup so EspExiting takes the AwaitingHello
// path. Build it with a non-replayed AccountSetup signal so the boot-anchor floor
// only matters for the EspExiting arming step.
// Setup: state already in EspAccountSetup with the strong post-AccountSetup gate
// satisfied so EspExiting takes the AwaitingHello path (session 330f73f3 fix:
// AccountSetupEntered alone is no longer sufficient — need AccountSetupProvisioningSucceeded).
// Build it with non-replayed signals so the boot-anchor floor only matters for the
// EspExiting arming step.
var engine = new DecisionEngine();
var state = FreshAtBoot();
state = engine.Reduce(state,
@@ -52,9 +54,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
state = engine.Reduce(state,
MakeSignal(1, DecisionSignalKind.EspPhaseChanged, AgentBoot.AddSeconds(1),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
state = engine.Reduce(state,
MakeSignal(2, DecisionSignalKind.AccountSetupProvisioningComplete, AgentBoot.AddSeconds(2))).NewState;
// Replayed CMTrace EspExiting from before the agent booted.
var replayed = MakeSignal(2, DecisionSignalKind.EspExiting, LogPast);
var replayed = MakeSignal(3, DecisionSignalKind.EspExiting, LogPast);
var step = engine.Reduce(state, replayed);
var helloSafety = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
@@ -150,6 +154,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
deviceSetupEnteredUtc: null,
accountSetupEnteredUtc: null,
finalizingEnteredUtc: null,
accountSetupProvisioningSucceededUtc: null,
espFinalExitUtc: null,
desktopArrivedUtc: null,
helloResolvedUtc: null,
@@ -9,11 +9,20 @@ 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).
/// Plan §6 Fix 8 + session 330f73f3 fix (2026-05-18). The Classic reducer must only promote
/// to <see cref="SessionStage.AwaitingHello"/> when the promotion is legitimate:
/// <list type="bullet">
/// <item><see cref="DecisionState.AccountSetupProvisioningSucceededUtc"/> is set
/// (AccountSetupCategory registry resolved as succeeded, OR the fallback fired),
/// i.e. ESP is genuinely done, OR</item>
/// <item><see cref="EnrollmentScenarioObservations.SkipUserEsp"/> is observed as
/// <c>true</c> (device-only / SkipUser flow where a single esp_exiting IS the
/// final one).</item>
/// </list>
/// The pre-fix gate "<c>AccountSetupEnteredUtc != null</c>" was too weak — Shell-Core
/// event 62407 fires at every ESP-page transition, and the Device→Account handoff would
/// promote and arm HelloSafety just seconds after AccountSetup entered, while apps were
/// still installing. See session 330f73f3 for the production failure mode.
/// <para>
/// Applies symmetrically to <see cref="DecisionSignalKind.EspPhaseChanged"/>(FinalizingSetup)
/// AND <see cref="DecisionSignalKind.EspExiting"/>. On an early / intermediate signal the
@@ -27,27 +36,38 @@ namespace AutopilotMonitor.DecisionCore.Tests
private static readonly DateTime Fixed = new DateTime(2026, 4, 23, 18, 57, 45, DateTimeKind.Utc);
// ======================================================= Guard matrix (shared contract)
// The AwaitingHello guard applies the SAME unlock logic to both EspPhaseChanged
// (FinalizingSetup) and EspExiting:
// unlock ⇔ (skipUser == true) OR (accountSetupEntered)
// The two theories below exercise every cell of the 3×2 matrix (skipUser ∈
// {true, false, null} × accountSetupEntered ∈ {false, true}) for each signal.
// Rows marked NEW fill pre-existing coverage gaps (both-unlock combinations, and the
// "null skipUser but AccountSetup-entered" unlock path that wasn't tested before).
// Unlock condition (post-330f73f3):
// unlock ⇔ (skipUser == true) OR (accountSetupSucceeded)
// AccountSetup *entered* alone is NOT sufficient (the pre-fix gate that caused 330f73f3).
// The two theories below sweep skipUser ∈ {true, false, null} × accountSetupSucceeded ∈
// {false, true}, and additionally cover an "entered-but-not-succeeded" row to pin the
// regression-blocking semantic.
[Theory]
[InlineData(true, false, SessionStage.AwaitingHello)] // skipUser unlocks
[InlineData(true, true, SessionStage.AwaitingHello)] // both unlocks present — NEW coverage
[InlineData(false, false, SessionStage.EspDeviceSetup)] // neither unlock → guard blocks
[InlineData(false, true, SessionStage.AwaitingHello)] // accountSetup unlocks
[InlineData(null, false, SessionStage.EspDeviceSetup)] // skipUser unknown → guard blocks
[InlineData(null, true, SessionStage.AwaitingHello)] // accountSetup overrides unknown skipUser — NEW coverage
// skipUser=true unlocks regardless of AccountSetup status
[InlineData(true, false, false, SessionStage.AwaitingHello)]
[InlineData(true, true, true, SessionStage.AwaitingHello)]
// skipUser=false AND succeeded=true → unlock
[InlineData(false, true, true, SessionStage.AwaitingHello)]
// skipUser=false AND only entered → blocked (the 330f73f3 fix)
[InlineData(false, true, false, SessionStage.EspAccountSetup)]
// neither unlock present → blocked
[InlineData(false, false, false, SessionStage.EspDeviceSetup)]
[InlineData(null, false, false, SessionStage.EspDeviceSetup)]
// skipUser unknown + only entered → blocked
[InlineData(null, true, false, SessionStage.EspAccountSetup)]
// skipUser unknown + succeeded → unlock
[InlineData(null, true, true, SessionStage.AwaitingHello)]
public void EspPhaseChanged_FinalizingSetup_guard_applies_skipUser_and_accountSetup_unlock_matrix(
bool? skipUser, bool accountSetupEntered, SessionStage expectedStage)
bool? skipUser, bool accountSetupEntered, bool accountSetupSucceeded, SessionStage expectedStage)
{
var engine = new DecisionEngine();
var initialStage = accountSetupEntered ? SessionStage.EspAccountSetup : SessionStage.EspDeviceSetup;
var state = BuildState(stage: initialStage, skipUser: skipUser, accountSetupEntered: accountSetupEntered);
var state = BuildState(
stage: initialStage,
skipUser: skipUser,
accountSetupEntered: accountSetupEntered,
accountSetupSucceeded: accountSetupSucceeded);
var step = engine.Reduce(state, MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 5));
@@ -61,18 +81,31 @@ namespace AutopilotMonitor.DecisionCore.Tests
}
[Theory]
[InlineData(true, false, SessionStage.AwaitingHello, true)]
[InlineData(true, true, SessionStage.AwaitingHello, true)] // NEW coverage
[InlineData(false, false, SessionStage.EspDeviceSetup, false)]
[InlineData(false, true, SessionStage.AwaitingHello, true)]
[InlineData(null, false, SessionStage.EspDeviceSetup, false)]
[InlineData(null, true, SessionStage.AwaitingHello, true)] // NEW coverage
// skipUser=true unlocks regardless of AccountSetup
[InlineData(true, false, false, SessionStage.AwaitingHello, true)]
[InlineData(true, true, true, SessionStage.AwaitingHello, true)]
// skipUser=false + succeeded → unlock + arm
[InlineData(false, true, true, SessionStage.AwaitingHello, true)]
// skipUser=false + only entered → blocked (the 330f73f3 fix); EspFinalExitUtc still set
[InlineData(false, true, false, SessionStage.EspAccountSetup, false)]
// neither unlock → blocked
[InlineData(false, false, false, SessionStage.EspDeviceSetup, false)]
[InlineData(null, false, false, SessionStage.EspDeviceSetup, false)]
// skipUser unknown + only entered → blocked
[InlineData(null, true, false, SessionStage.EspAccountSetup, false)]
// skipUser unknown + succeeded → unlock
[InlineData(null, true, true, SessionStage.AwaitingHello, true)]
public void EspExiting_guard_controls_AwaitingHello_transition_and_arms_HelloSafety_only_on_unlock(
bool? skipUser, bool accountSetupEntered, SessionStage expectedStage, bool expectHelloSafetyArm)
bool? skipUser, bool accountSetupEntered, bool accountSetupSucceeded,
SessionStage expectedStage, bool expectHelloSafetyArm)
{
var engine = new DecisionEngine();
var initialStage = accountSetupEntered ? SessionStage.EspAccountSetup : SessionStage.EspDeviceSetup;
var state = BuildState(stage: initialStage, skipUser: skipUser, accountSetupEntered: accountSetupEntered);
var state = BuildState(
stage: initialStage,
skipUser: skipUser,
accountSetupEntered: accountSetupEntered,
accountSetupSucceeded: accountSetupSucceeded);
var step = engine.Reduce(state, MakeExitingSignal(ordinal: 5));
@@ -94,6 +127,106 @@ namespace AutopilotMonitor.DecisionCore.Tests
}
}
// ====================================================== Session 330f73f3 reproduction
// The bug session in concrete terms: AccountSetup entered at 07:04:47, intermediate
// esp_exiting (Device→Account 62407) at 07:04:56 — 9 s later. The pre-fix reducer
// promoted to AwaitingHello + armed HelloSafety against an in-progress AccountSetup;
// 5 min later the HelloSafety synthetic-timeout fired enrollment_complete while apps
// were still installing.
[Fact]
public void Session_330f73f3_intermediate_EspExiting_after_AccountSetupEntered_does_not_promote()
{
var engine = new DecisionEngine();
var state = BuildState(
stage: SessionStage.EspAccountSetup,
skipUser: false,
accountSetupEntered: true,
accountSetupSucceeded: false);
var step = engine.Reduce(state, MakeExitingSignal(ordinal: 5));
// Stage unchanged — the intermediate 62407 is not a promotion trigger anymore.
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
// HelloSafety NOT armed — that was the load-bearing bit of the bug.
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
// Observability fact still recorded so downstream classifiers + Inspector see it.
Assert.NotNull(step.NewState.EspFinalExitUtc);
}
[Fact]
public void Session_330f73f3_AccountSetupProvisioningComplete_after_intermediate_EspExiting_does_deferred_promote()
{
// Continuation of the bug fix: the first esp_exiting was ignored (intermediate),
// and Windows never fires a second one in this scenario (verified in 330f73f3 events).
// When the strong fact finally arrives, the handler performs the deferred promotion
// and arms HelloSafety against the AccountSetup-succeeded instant — not the
// historical EspFinalExitUtc — so the 5-min window is meaningful.
var engine = new DecisionEngine();
var state = BuildState(
stage: SessionStage.EspAccountSetup,
skipUser: false,
accountSetupEntered: true,
accountSetupSucceeded: false);
// Step 1: intermediate EspExiting → ignored.
var afterExiting = engine.Reduce(state, MakeExitingSignal(ordinal: 5)).NewState;
Assert.Equal(SessionStage.EspAccountSetup, afterExiting.Stage);
Assert.NotNull(afterExiting.EspFinalExitUtc);
// Step 2: AccountSetupProvisioningComplete arrives → deferred promote + HelloSafety arm.
var completeSignal = MakeAccountSetupCompleteSignal(ordinal: 6, occurredAtUtc: Fixed.AddMinutes(5));
var afterComplete = engine.Reduce(afterExiting, completeSignal);
Assert.Equal(SessionStage.AwaitingHello, afterComplete.NewState.Stage);
Assert.NotNull(afterComplete.NewState.AccountSetupProvisioningSucceededUtc);
var helloSafety = Assert.Single(
afterComplete.NewState.Deadlines,
d => d.Name == DeadlineNames.HelloSafety);
// Window is 300 s from the fact instant (not the historical EspExiting at ordinal 5).
Assert.Equal(Fixed.AddMinutes(5).AddSeconds(300), helloSafety.DueAtUtc);
Assert.Contains(afterComplete.Effects,
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.HelloSafety);
}
[Fact]
public void AccountSetupProvisioningComplete_after_guard_blocked_FinalizingSetup_does_deferred_promote()
{
// Sibling-ordering: ShellCoreTracker's FinalizingSetupPhaseTriggered (62404 / first
// 62407) is forwarded as EspPhaseChanged(FinalizingSetup) — the adapter fire-once-
// dedupes this. If it arrives BEFORE AccountSetupProvisioningComplete, the new strong
// gate blocks the stage transition but FinalizingEnteredUtc is still recorded. With
// no second Finalizing forward coming, the deferred-promotion path in
// HandleAccountSetupProvisioningCompleteV1 must also unlock from FinalizingEnteredUtc,
// not only EspFinalExitUtc.
var engine = new DecisionEngine();
var state = BuildState(
stage: SessionStage.EspAccountSetup,
skipUser: false,
accountSetupEntered: true,
accountSetupSucceeded: false);
// Step 1: guard-blocked Finalizing.
var afterFinalizing = engine.Reduce(state,
MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 5)).NewState;
Assert.Equal(SessionStage.EspAccountSetup, afterFinalizing.Stage);
Assert.NotNull(afterFinalizing.FinalizingEnteredUtc);
Assert.Null(afterFinalizing.EspFinalExitUtc);
Assert.DoesNotContain(afterFinalizing.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
// Step 2: AccountSetupProvisioningComplete → deferred promote + HelloSafety arm
// anchored at the AccountSetup-succeeded instant.
var afterComplete = engine.Reduce(afterFinalizing,
MakeAccountSetupCompleteSignal(ordinal: 6, occurredAtUtc: Fixed.AddMinutes(5)));
Assert.Equal(SessionStage.AwaitingHello, afterComplete.NewState.Stage);
var helloSafety = Assert.Single(
afterComplete.NewState.Deadlines,
d => d.Name == DeadlineNames.HelloSafety);
Assert.Equal(Fixed.AddMinutes(5).AddSeconds(300), helloSafety.DueAtUtc);
Assert.Contains(afterComplete.Effects,
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.HelloSafety);
}
// ====================================================== Codex review — ordering contract
[Fact]
@@ -243,9 +376,12 @@ namespace AutopilotMonitor.DecisionCore.Tests
private static DecisionState BuildState(
SessionStage stage,
bool? skipUser,
bool accountSetupEntered = false)
bool accountSetupEntered = false,
bool accountSetupSucceeded = false)
{
var builder = DecisionState.CreateInitial("s", "t")
// Pin AgentBootUtc to a time before Fixed so EffectiveDeadlineBase doesn't floor
// the fixture's deterministic timestamps to wall-clock-now.
var builder = DecisionState.CreateInitial("s", "t", Fixed.AddDays(-1))
.ToBuilder()
.WithStage(stage)
.WithStepIndex(2)
@@ -265,6 +401,10 @@ namespace AutopilotMonitor.DecisionCore.Tests
evidenceOrdinal: 0);
}
if (accountSetupEntered) builder.AccountSetupEnteredUtc = new SignalFact<DateTime>(Fixed.AddMinutes(-1), 1);
// Session 330f73f3 fix: pre-seed the strong post-AccountSetup gate when the test
// wants to exercise the "ESP genuinely done" path.
if (accountSetupSucceeded)
builder.AccountSetupProvisioningSucceededUtc = new SignalFact<DateTime>(Fixed.AddSeconds(-30), 1);
return builder.Build();
}
@@ -298,5 +438,26 @@ namespace AutopilotMonitor.DecisionCore.Tests
occurredAtUtc: Fixed,
sourceOrigin: "ShellCoreTracker",
evidence: new Evidence(EvidenceKind.Raw, "esp_exiting", "ESP phase exiting"));
private static DecisionSignal MakeAccountSetupCompleteSignal(long ordinal, DateTime occurredAtUtc) =>
new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: DecisionSignalKind.AccountSetupProvisioningComplete,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: "ProvisioningStatusTracker",
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "provisioning-status-tracker-v1",
summary: "AccountSetup succeeded",
derivationInputs: new Dictionary<string, string>
{
["accountSetupResolved"] = "true",
}),
payload: new Dictionary<string, string>
{
["accountSetupResolved"] = "true",
});
}
}
@@ -238,7 +238,9 @@ namespace AutopilotMonitor.DecisionCore.Tests
/// <summary>
/// Reaches the typical post-AccountSetup state for a Classic-v1 + Hello-disabled
/// enrollment: SessionStarted, ESP DeviceSetup → AccountSetup, HelloPolicyDetected
/// (helloEnabled=false). No EspExiting yet.
/// (helloEnabled=false), and AccountSetupProvisioningComplete (session 330f73f3 fix —
/// the strong post-AccountSetup gate that <see cref="DecisionEngine"/>.<c>ShouldTransitionToAwaitingHello</c>
/// requires before a subsequent EspExiting promotes to AwaitingHello). No EspExiting yet.
/// </summary>
private static DecisionState ProgressToAccountSetupWithHelloDisabled(DecisionEngine engine)
{
@@ -250,6 +252,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(3),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.AccountSetupProvisioningComplete,
T0.AddMinutes(4).AddSeconds(30), null)).NewState;
return state;
}
@@ -101,7 +101,9 @@ namespace AutopilotMonitor.DecisionCore.Tests
// The c117946b debrief (2026-05-12) added `lastFailureTrigger` so the
// EnrollmentTerminationHandler can discriminate which Failed pathway fired
// and only promote in-flight installs on ESP-Apps timeout → 12 slots.
Assert.Equal(12, facts.Count);
// Session 330f73f3 fix (2026-05-18) added `accountSetupProvisioningSucceededUtc`
// as the strong post-AccountSetup gate → 13 slots.
Assert.Equal(13, facts.Count);
Assert.All(facts.Values, v => Assert.Null(v));
}
@@ -211,10 +213,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
// PR-B (2026-05-04): 11 SignalFact properties on DecisionState (was 15;
// the 4 WG-Part-2 facts were removed). c117946b debrief (2026-05-12):
// `lastFailureTrigger` added for the EnrollmentTerminationHandler's
// 4-check discriminator → 12. If this number ever changes, both the
// count expectation AND the actual snapshot output need to evolve in
// lockstep.
Assert.Equal(12, expectedFactKeys.Count);
// 4-check discriminator → 12. Session 330f73f3 fix (2026-05-18) added
// `accountSetupProvisioningSucceededUtc` as the strong post-AccountSetup
// gate → 13. If this number ever changes, both the count expectation AND
// the actual snapshot output need to evolve in lockstep.
Assert.Equal(13, expectedFactKeys.Count);
var snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t"));
var facts = (Dictionary<string, object?>)snapshot["facts"]!;
@@ -688,11 +688,127 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// </summary>
private static bool ShouldTransitionToAwaitingHello(DecisionState state)
{
if (state.AccountSetupEnteredUtc != null) return true;
// Session 330f73f3 fix (2026-05-18): entering AccountSetup is no longer sufficient.
// Shell-Core event 62407 (CommercialOOBE_ESPProgress_Page_Exiting) fires at every
// ESP-page transition — the first occurrence is the Device→Account handoff, NOT
// the genuine final exit. Promoting on AccountSetupEnteredUtc alone armed
// HelloSafety against the wrong baseline; HelloSafety then fired its 5-min synthetic
// timeout while ESP AccountSetup and app installs were still in progress, and
// FinalizingGrace marked the session terminal mid-flight.
//
// The strong post-AccountSetup gate is now <see cref="DecisionState.AccountSetupProvisioningSucceededUtc"/>,
// posted by ProvisioningStatusTracker once <c>AccountSetupCategory.Status</c> resolves
// to <c>categorySucceeded=true</c> (or the fallback fires — analog to DeviceSetup).
if (state.AccountSetupProvisioningSucceededUtc != null) return true;
// SkipUser flow — no User-ESP page runs; the first esp_exiting IS the genuine final
// exit. Existing observation contract preserved.
if (state.ScenarioObservations.SkipUserEsp?.Value == true) return true;
return false;
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/>. Records
/// <see cref="DecisionState.AccountSetupProvisioningSucceededUtc"/> for the
/// <see cref="ShouldTransitionToAwaitingHello"/> guard.
/// <para>
/// Two arrival orderings are handled:
/// <list type="bullet">
/// <item><b>ESP terminal-handoff signal arrives later (typical):</b> stage unchanged
/// here. The next <see cref="DecisionSignalKind.EspExiting"/> or
/// <see cref="DecisionSignalKind.EspPhaseChanged"/>(FinalizingSetup) reads the
/// new fact via <see cref="ShouldTransitionToAwaitingHello"/> and promotes.</item>
/// <item><b>ESP terminal-handoff signal already arrived (session 330f73f3 ordering):</b>
/// either the intermediate Device→Account 62407 was ignored at the time
/// (<see cref="DecisionState.EspFinalExitUtc"/> set), OR an
/// <c>EspPhaseChanged(FinalizingSetup)</c> from <c>ShellCoreTracker</c>'s
/// <c>FinalizingSetupPhaseTriggered</c> already landed
/// (<see cref="DecisionState.FinalizingEnteredUtc"/> set) but the guard
/// blocked the stage transition and the adapter's fire-once flag means no
/// second copy will arrive. With the strong gate now satisfied we perform
/// the deferred promotion here — transition to
/// <see cref="SessionStage.AwaitingHello"/> and arm the Hello-safety deadline —
/// using the AccountSetupSucceeded instant as the deadline base (NOT the
/// historical, ignored EspFinalExitUtc / FinalizingEnteredUtc — that would
/// immediately fire the 5-min window).</item>
/// </list>
/// Set-once on the fact: re-posts only update bookkeeping.
/// </para>
/// </summary>
private DecisionStep HandleAccountSetupProvisioningCompleteV1(DecisionState state, DecisionSignal signal)
{
var nextStep = state.StepIndex + 1;
var builder = state.ToBuilder()
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
var alreadyRecorded = state.AccountSetupProvisioningSucceededUtc != null;
if (!alreadyRecorded)
{
builder.AccountSetupProvisioningSucceededUtc =
new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
}
// Deferred-promotion path. Activates only when:
// 1. This is the first time we record the fact (avoid double-arming on replay).
// 2. Some ESP terminal-handoff signal already landed but was guard-blocked:
// EspFinalExitUtc (intermediate EspExiting was ignored) OR
// FinalizingEnteredUtc (EspPhaseChanged(FinalizingSetup) arrived first; the
// ShellCoreTracker adapter fire-once-dedupes this so no replay will come).
// 3. We are still parked in an ESP stage waiting on the strong gate.
// 4. SkipUserEsp is NOT observed (that path is handled by the existing
// ShouldTransitionToAwaitingHello short-circuit).
var shouldPromote = !alreadyRecorded
&& (state.EspFinalExitUtc != null || state.FinalizingEnteredUtc != null)
&& (state.Stage == SessionStage.EspDeviceSetup
|| state.Stage == SessionStage.EspAccountSetup
|| state.Stage == SessionStage.SessionStarted)
&& state.ScenarioObservations.SkipUserEsp?.Value != true;
if (!shouldPromote)
{
var newState = builder.Build();
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: state.Stage,
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.AccountSetupProvisioningComplete));
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
}
// Mirror of HandleEspExitingV1's promote branch, using this signal's instant as
// the deadline base. EffectiveDeadlineBase still floors at AgentBootUtc to keep
// the replay-safety guarantee.
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow);
var helloSafety = new ActiveDeadline(
name: DeadlineNames.HelloSafety,
dueAtUtc: dueAtUtc,
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety,
});
builder = builder
.WithStage(SessionStage.AwaitingHello)
.AddDeadline(helloSafety);
var promotedState = builder.Build();
var promotedTransition = BuildTakenTransition(
before: state,
signal: signal,
toStage: SessionStage.AwaitingHello,
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.AccountSetupProvisioningComplete) + ":DeferredPromote");
var promotedEffects = new[]
{
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: helloSafety),
};
return new DecisionStep(promotedState, promotedTransition, promotedEffects);
}
/// <summary>
/// Floor a deadline-arming base timestamp at the current agent run's boot anchor.
/// Replay-safety guard: when a signal carries a historical <c>OccurredAtUtc</c> from
@@ -72,6 +72,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
// ----- SelfDeploying + Device-Only (DecisionEngine.SelfDeploying.cs) -----
(DecisionSignalKind.DeviceSetupProvisioningComplete, 1) => HandleDeviceSetupProvisioningCompleteV1(state, signal),
// ----- AccountSetup completion gate (DecisionEngine.Shared.cs) — session 330f73f3 fix -----
(DecisionSignalKind.AccountSetupProvisioningComplete, 1) => HandleAccountSetupProvisioningCompleteV1(state, signal),
// ----- WhiteGlove Part 1 (DecisionEngine.WhiteGlove.cs) -----
(DecisionSignalKind.WhiteGloveShellCoreSuccess, 1) => HandleWhiteGloveShellCoreSuccessV1(state, signal),
(DecisionSignalKind.WhiteGloveSealingPatternDetected, 1) => HandleWhiteGloveSealingPatternDetectedV1(state, signal),
@@ -81,6 +81,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
timestamps["accountSetupEntered"] = FormatUtc(state.AccountSetupEnteredUtc.Value);
evidence["accountSetupEntered"] = TimestampedEvidence(state.AccountSetupEnteredUtc);
}
// Session 330f73f3 fix — strong post-AccountSetup gate. Included in the census so
// enrollment_complete audit + final-status.json show whether the genuine
// AccountSetup-done observation was present when the session terminated.
if (state.AccountSetupProvisioningSucceededUtc != null)
{
seen.Add("account_setup_provisioning_complete");
timestamps["accountSetupProvisioningComplete"] = FormatUtc(state.AccountSetupProvisioningSucceededUtc.Value);
evidence["accountSetupProvisioningComplete"] = TimestampedEvidence(state.AccountSetupProvisioningSucceededUtc);
}
// WhiteGlove Part-1 sealing observations — engine-internal, no UTC fact.
var obs = state.ScenarioObservations;
@@ -39,8 +39,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>
/// Build a compact snapshot dictionary for the given <paramref name="state"/>.
/// All 15 <see cref="SignalFact{T}"/> properties of <see cref="DecisionState"/>
/// are serialized under <c>facts</c>; absent facts appear as JSON <c>null</c>.
/// All <see cref="SignalFact{T}"/> properties of <see cref="DecisionState"/> are
/// serialized under <c>facts</c>; absent facts appear as JSON <c>null</c>.
/// </summary>
public static Dictionary<string, object?> Build(DecisionState state)
{
@@ -51,6 +51,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
["currentEnrollmentPhase"] = SerializeFact(state.CurrentEnrollmentPhase, v => v.ToString()),
["deviceSetupEnteredUtc"] = SerializeFact(state.DeviceSetupEnteredUtc, FormatUtc),
["accountSetupEnteredUtc"] = SerializeFact(state.AccountSetupEnteredUtc, FormatUtc),
["accountSetupProvisioningSucceededUtc"] = SerializeFact(state.AccountSetupProvisioningSucceededUtc, FormatUtc),
["finalizingEnteredUtc"] = SerializeFact(state.FinalizingEnteredUtc, FormatUtc),
["espFinalExitUtc"] = SerializeFact(state.EspFinalExitUtc, FormatUtc),
["desktopArrivedUtc"] = SerializeFact(state.DesktopArrivedUtc, FormatUtc),
@@ -18,6 +18,15 @@ namespace AutopilotMonitor.DecisionCore.Signals
HelloResolved,
ImeUserSessionCompleted,
DeviceSetupProvisioningComplete,
// Session 330f73f3 fix (2026-05-18) — strong "AccountSetup truly succeeded" fact.
// Posted by ProvisioningStatusTracker once AccountSetupCategory.Status resolves to
// categorySucceeded=true OR the fallback fires (all subcategories succeeded/notRequired
// but Windows never set the boolean — analog to the existing DeviceSetup fallback).
// Consumed by <c>ShouldTransitionToAwaitingHello</c>: entering AccountSetup is no longer
// sufficient on its own, because Shell-Core event 62407 fires at every ESP-page
// transition and the first occurrence (Device→Account handoff) is NOT the genuine
// final exit.
AccountSetupProvisioningComplete,
AppInstallCompleted,
AppInstallFailed,
WhiteGloveShellCoreSuccess,
@@ -41,6 +41,7 @@ namespace AutopilotMonitor.DecisionCore.State
SignalFact<DateTime>? deviceSetupEnteredUtc,
SignalFact<DateTime>? accountSetupEnteredUtc,
SignalFact<DateTime>? finalizingEnteredUtc,
SignalFact<DateTime>? accountSetupProvisioningSucceededUtc,
SignalFact<DateTime>? espFinalExitUtc,
SignalFact<DateTime>? desktopArrivedUtc,
SignalFact<DateTime>? helloResolvedUtc,
@@ -77,6 +78,7 @@ namespace AutopilotMonitor.DecisionCore.State
DeviceSetupEnteredUtc = deviceSetupEnteredUtc;
AccountSetupEnteredUtc = accountSetupEnteredUtc;
FinalizingEnteredUtc = finalizingEnteredUtc;
AccountSetupProvisioningSucceededUtc = accountSetupProvisioningSucceededUtc;
EspFinalExitUtc = espFinalExitUtc;
DesktopArrivedUtc = desktopArrivedUtc;
HelloResolvedUtc = helloResolvedUtc;
@@ -115,6 +117,19 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact<DateTime>? FinalizingEnteredUtc { get; }
/// <summary>
/// Set when the ESP <c>AccountSetupCategory.Status</c> registry resolves to
/// <c>categorySucceeded=true</c>, or when the fallback fires (all subcategories
/// succeeded/notRequired but Windows never set the boolean — analog to the existing
/// DeviceSetup fallback). This is the strong "User-ESP is genuinely finished" fact;
/// <see cref="AccountSetupEnteredUtc"/> only signals page-entry and is not sufficient.
/// Posted via <see cref="Signals.DecisionSignalKind.AccountSetupProvisioningComplete"/>.
/// Null while AccountSetup is still in_progress, missing entirely on flows that skip
/// AccountSetup (handled by the <c>SkipUserEsp</c> observation in
/// <see cref="ScenarioObservations"/>).
/// </summary>
public SignalFact<DateTime>? AccountSetupProvisioningSucceededUtc { get; }
// --- Signal-induced facts (with source ordinal for evidence trace) ---
public SignalFact<DateTime>? EspFinalExitUtc { get; }
@@ -243,6 +258,7 @@ namespace AutopilotMonitor.DecisionCore.State
deviceSetupEnteredUtc: null,
accountSetupEnteredUtc: null,
finalizingEnteredUtc: null,
accountSetupProvisioningSucceededUtc: null,
espFinalExitUtc: null,
desktopArrivedUtc: null,
helloResolvedUtc: null,
@@ -27,6 +27,7 @@ namespace AutopilotMonitor.DecisionCore.State
DeviceSetupEnteredUtc = source.DeviceSetupEnteredUtc;
AccountSetupEnteredUtc = source.AccountSetupEnteredUtc;
FinalizingEnteredUtc = source.FinalizingEnteredUtc;
AccountSetupProvisioningSucceededUtc = source.AccountSetupProvisioningSucceededUtc;
EspFinalExitUtc = source.EspFinalExitUtc;
DesktopArrivedUtc = source.DesktopArrivedUtc;
HelloResolvedUtc = source.HelloResolvedUtc;
@@ -54,6 +55,7 @@ namespace AutopilotMonitor.DecisionCore.State
public SignalFact<DateTime>? DeviceSetupEnteredUtc { get; set; }
public SignalFact<DateTime>? AccountSetupEnteredUtc { get; set; }
public SignalFact<DateTime>? FinalizingEnteredUtc { get; set; }
public SignalFact<DateTime>? AccountSetupProvisioningSucceededUtc { get; set; }
public SignalFact<DateTime>? EspFinalExitUtc { get; set; }
public SignalFact<DateTime>? DesktopArrivedUtc { get; set; }
public SignalFact<DateTime>? HelloResolvedUtc { get; set; }
@@ -181,6 +183,7 @@ namespace AutopilotMonitor.DecisionCore.State
deviceSetupEnteredUtc: DeviceSetupEnteredUtc,
accountSetupEnteredUtc: AccountSetupEnteredUtc,
finalizingEnteredUtc: FinalizingEnteredUtc,
accountSetupProvisioningSucceededUtc: AccountSetupProvisioningSucceededUtc,
espFinalExitUtc: EspFinalExitUtc,
desktopArrivedUtc: DesktopArrivedUtc,
helloResolvedUtc: HelloResolvedUtc,
@@ -0,0 +1,9 @@
# AccountSetupProvisioningComplete v1 — session 330f73f3 fix (2026-05-18).
# Posted by ProvisioningStatusTracker (via EspAndHelloTrackerAdapter in production) once
# AccountSetupCategory.Status registry resolves to categorySucceeded=true, or when the
# fallback fires after 30 s of all subcategories sitting at succeeded/notRequired without
# Windows ever flipping the boolean (same fallback shape as DeviceSetup).
# Reducer: HandleAccountSetupProvisioningCompleteV1 → records AccountSetupProvisioningSucceededUtc.
# If EspExiting has already been recorded (intermediate exit was ignored on the way in),
# also performs the deferred promotion to AwaitingHello + arms HelloSafety.
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AccountSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-18T07:30:00.0000000Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-and-hello-tracker-v1","Summary":"AccountSetupCategory provisioning completed (coordinator-forwarded)","DerivationInputs":{"subSource":"ProvisioningStatusTracker","accountSetupResolved":"true"}},"Payload":{"accountSetupResolved":"true"}}