diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index a848267e..438db35a 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-1281
+1291
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/AdvisoryCompletionFalsePositiveGuardTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AdvisoryCompletionFalsePositiveGuardTests.cs
new file mode 100644
index 00000000..3bc40a0f
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AdvisoryCompletionFalsePositiveGuardTests.cs
@@ -0,0 +1,406 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Serialization;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests
+{
+ ///
+ /// Session 1924092e (2026-07-10) — false-positive guards for the esp-exit-variant
+ /// advisory_completion window. Field failure: the IME logged an AccountSetup phase
+ /// line pre-sign-in (defaultuser0/AutoLogon frame), seven seconds later the Device→Account
+ /// handoff exit (Shell-Core 62407, errorCode=0) armed the 30-min resolution window, the
+ /// deadline survived two reboots via state recovery, and fired mid-AccountSetup while apps
+ /// were actively installing (5/39 done) → enrollment_failed:
+ /// esp_exit_without_completion_evidence on a live enrollment. 65 sessions platform-wide
+ /// hit this between 2026-06-15 and 2026-07-10. Coverage:
+ ///
+ /// - Reboot cancel — SystemRebootObserved / EspResumed cancel the
+ /// esp-exit-variant window (the arming exit predates a reboot ⇒ pre-reboot page
+ /// close, not the 1ec8f4c6 dead-end) but never the advisory variant.
+ /// - Enforcement-progress re-arm — a fire while apps kept reaching terminal states
+ /// or the ESP re-asserted a user phase re-arms instead of failing; convergent
+ /// (a second fire without NEW progress fails).
+ /// - Full session-1924092e replay: handoff exit → reboot → desktop → active installs
+ /// → stray recovered-timer fire dead-ends → provisioning-complete → Completed.
+ /// - Regression pins: the true 1ec8f4c6 dead-end still fails, the advisory variant
+ /// keeps its un-defang semantics, legacy deadlines without baselines resolve as
+ /// before, baselines survive snapshot round-trips.
+ ///
+ ///
+ public sealed class AdvisoryCompletionFalsePositiveGuardTests
+ {
+ private static readonly DateTime T0 = new DateTime(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc);
+
+ private static DecisionSignal MakeSignal(
+ long ordinal,
+ DecisionSignalKind kind,
+ DateTime occurredAtUtc,
+ IReadOnlyDictionary? payload = null)
+ {
+ return new DecisionSignal(
+ sessionSignalOrdinal: ordinal,
+ sessionTraceOrdinal: ordinal,
+ kind: kind,
+ kindSchemaVersion: 1,
+ occurredAtUtc: occurredAtUtc,
+ sourceOrigin: "test",
+ evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
+ payload: payload);
+ }
+
+ private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) =>
+ MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
+ new Dictionary { [SignalPayloadKeys.Deadline] = deadlineName });
+
+ private static DecisionSignal AppInstallCompleted(long ordinal, DateTime occurredAtUtc, string appId) =>
+ MakeSignal(ordinal, DecisionSignalKind.AppInstallCompleted, occurredAtUtc,
+ new Dictionary { ["appId"] = appId, ["newState"] = "Installed" });
+
+ private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
+ state.Deadlines.FirstOrDefault(d => d.Name == name);
+
+ private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) =>
+ step.Effects.Single(e =>
+ e.Kind == DecisionEffectKind.EmitEventTimelineEntry
+ && e.Parameters != null
+ && e.Parameters.TryGetValue("eventType", out var et)
+ && et == eventType);
+
+ ///
+ /// Drives a Classic user-driven session into EspAccountSetup: full ESP, Hello policy
+ /// disabled, DeviceSetup → AccountSetup phase lines. In the 1924092e replay the
+ /// AccountSetup line is the premature pre-sign-in IME false positive — which is
+ /// invisible to the reducer (it looks identical to a genuine entry) and is exactly
+ /// why the guards act on later evidence instead of the arming condition.
+ ///
+ private static DecisionState SetupClassicAccountSetupSession(
+ DecisionEngine engine,
+ bool allowContinueAnyway = false)
+ {
+ var state = DecisionState.CreateInitial("sess-1924092e", "tenant-1924092e", T0);
+ state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
+ state = engine.Reduce(state, MakeSignal(
+ 5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1),
+ new Dictionary
+ {
+ [SignalPayloadKeys.SkipUserEsp] = "false",
+ [SignalPayloadKeys.SkipDeviceEsp] = "false",
+ [SignalPayloadKeys.EspAllowContinueAnyway] = allowContinueAnyway ? "true" : "false",
+ })).NewState;
+ state = engine.Reduce(state, MakeSignal(
+ 8, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1),
+ new Dictionary { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
+ state = engine.Reduce(state, MakeSignal(
+ 10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
+ new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
+ state = engine.Reduce(state, MakeSignal(
+ 20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(5),
+ new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
+ return state;
+ }
+
+ /// Guard-blocked post-AccountSetup exit — arms the esp-exit-variant window.
+ private static DecisionState ArmEspExitWindow(DecisionEngine engine, DecisionState state, long ordinal = 50)
+ {
+ var armed = engine.Reduce(state, MakeSignal(ordinal, DecisionSignalKind.EspExiting, T0.AddMinutes(5.1))).NewState;
+ Assert.NotNull(FindDeadline(armed, DeadlineNames.AdvisoryCompletion));
+ Assert.Null(armed.Outcome);
+ return armed;
+ }
+
+ // ============================================================ reboot cancel ====
+
+ [Fact]
+ public void SystemReboot_CancelsEspExitVariantWindow_AndEmitsCancelEffect()
+ {
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine);
+ state = ArmEspExitWindow(engine, state);
+
+ var step = engine.Reduce(state, MakeSignal(
+ 60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(19),
+ new Dictionary { ["previousExitType"] = "reboot_kill" }));
+
+ Assert.Null(FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion));
+ var cancel = step.Effects.Single(e => e.Kind == DecisionEffectKind.CancelDeadline);
+ Assert.Equal(DeadlineNames.AdvisoryCompletion, cancel.CancelDeadlineName);
+ // The reboot's own observability is untouched.
+ SingleTimelineEffect(step, "system_reboot_detected");
+ Assert.Null(step.NewState.Outcome);
+ }
+
+ [Fact]
+ public void EspResumed_CancelsEspExitVariantWindow()
+ {
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine);
+ state = ArmEspExitWindow(engine, state);
+
+ var step = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.EspResumed, T0.AddMinutes(19)));
+
+ Assert.Null(FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion));
+ var cancel = step.Effects.Single(e => e.Kind == DecisionEffectKind.CancelDeadline);
+ Assert.Equal(DeadlineNames.AdvisoryCompletion, cancel.CancelDeadlineName);
+ }
+
+ [Fact]
+ public void SystemReboot_LeavesAdvisoryVariantWindowArmed()
+ {
+ // The advisory variant is anchored to a REAL ESP terminal failure — a reboot does
+ // not un-happen it, and the window must still un-defang the failure on expiry.
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine, allowContinueAnyway: true);
+ state = engine.Reduce(state, MakeSignal(
+ 50, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(6),
+ new Dictionary
+ {
+ ["failureType"] = "Provisioning_AccountSetup_Apps_Failed",
+ ["failedSubcategory"] = "Apps",
+ ["category"] = "AccountSetup",
+ })).NewState;
+ Assert.NotNull(state.EspAdvisoryFailureRecordedUtc);
+ var armed = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
+ Assert.NotNull(armed);
+
+ var step = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(10)));
+
+ var after = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
+ Assert.NotNull(after);
+ Assert.Equal(armed!.DueAtUtc, after!.DueAtUtc);
+ Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.CancelDeadline);
+ }
+
+ [Fact]
+ public void SystemReboot_WithoutArmedWindow_EmitsNoCancelEffect()
+ {
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine);
+
+ var step = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(10)));
+
+ Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.CancelDeadline);
+ SingleTimelineEffect(step, "system_reboot_detected");
+ }
+
+ // ================================================ enforcement-progress re-arm ====
+
+ [Fact]
+ public void DeadlineFired_EspExitVariant_AppProgressSinceArming_RearmsInsteadOfFailing()
+ {
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine);
+ state = ArmEspExitWindow(engine, state, ordinal: 50);
+ // Enforcement demonstrably continued after the arming exit.
+ state = engine.Reduce(state, AppInstallCompleted(60, T0.AddMinutes(20), "app-winrar")).NewState;
+
+ var fireAt = T0.AddMinutes(35.1);
+ var step = engine.Reduce(state, DeadlineFired(70, fireAt, DeadlineNames.AdvisoryCompletion));
+
+ // Not failed — the window is re-based 30 min from the fire.
+ Assert.True(step.Transition.Taken);
+ Assert.Equal(state.Stage, step.NewState.Stage);
+ Assert.Null(step.NewState.Outcome);
+ var rearmed = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
+ Assert.NotNull(rearmed);
+ Assert.Equal(fireAt.AddMinutes(30), rearmed!.DueAtUtc);
+ var schedule = step.Effects.Single(e => e.Kind == DecisionEffectKind.ScheduleDeadline);
+ Assert.Equal(DeadlineNames.AdvisoryCompletion, schedule.Deadline!.Name);
+
+ // The re-arm is visible on the timeline with the new due-time.
+ var waiting = SingleTimelineEffect(step, "completion_waiting");
+ Assert.Contains("EnforcementActive", waiting.Parameters!["trigger"]);
+ Assert.Equal(rearmed.DueAtUtc.ToString("o"), waiting.Parameters!["resolutionDeadlineDueAtUtc"]);
+
+ // Convergence: a second fire without NEW progress since the re-arm fails.
+ var second = engine.Reduce(step.NewState, DeadlineFired(80, fireAt.AddMinutes(30), DeadlineNames.AdvisoryCompletion));
+ Assert.Equal(SessionStage.Failed, second.NewState.Stage);
+ Assert.Equal(SessionOutcome.EnrollmentFailed, second.NewState.Outcome);
+ var failed = SingleTimelineEffect(second, "enrollment_failed");
+ Assert.Equal("esp_exit_without_completion_evidence", failed.Parameters!["reason"]);
+ }
+
+ [Fact]
+ public void DeadlineFired_EspExitVariant_PhaseReassertedSinceArming_Rearms()
+ {
+ // No app terminals, but the ESP/IME re-asserted AccountSetup after the arming exit
+ // (session 1924092e: esp_phase_changed AccountSetup at 12:26:37, exit at 12:05:08).
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine);
+ state = ArmEspExitWindow(engine, state, ordinal: 50);
+ state = engine.Reduce(state, MakeSignal(
+ 60, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(24),
+ new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
+
+ var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(35.1), DeadlineNames.AdvisoryCompletion));
+
+ Assert.Null(step.NewState.Outcome);
+ Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion));
+ }
+
+ [Fact]
+ public void DeadlineFired_EspExitVariant_DesktopOnly_NoEnforcementProgress_StillFails()
+ {
+ // Desktop arrival alone is NOT enforcement progress — the true 1ec8f4c6 dead-end
+ // ends exactly there (page closed, user at the desktop, nothing installs). Pins
+ // that the re-arm guard does not swallow the genuine dead-end verdict.
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine);
+ state = ArmEspExitWindow(engine, state, ordinal: 50);
+ state = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
+
+ var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(35.1), DeadlineNames.AdvisoryCompletion));
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+ Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
+ var failed = SingleTimelineEffect(step, "enrollment_failed");
+ Assert.Equal("esp_exit_without_completion_evidence", failed.Parameters!["reason"]);
+ }
+
+ [Fact]
+ public void DeadlineFired_AdvisoryVariant_AppProgress_DoesNotRearm_StillUnDefangs()
+ {
+ // Variant split: the advisory window resolves a REAL ESP failure. App progress
+ // after the failure must not postpone the un-defang.
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine, allowContinueAnyway: true);
+ state = engine.Reduce(state, MakeSignal(
+ 50, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(6),
+ new Dictionary
+ {
+ ["failureType"] = "Provisioning_AccountSetup_Apps_Failed",
+ ["failedSubcategory"] = "Apps",
+ ["category"] = "AccountSetup",
+ })).NewState;
+ state = engine.Reduce(state, AppInstallCompleted(60, T0.AddMinutes(20), "app-other")).NewState;
+
+ var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(36), DeadlineNames.AdvisoryCompletion));
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+ var failed = SingleTimelineEffect(step, "enrollment_failed");
+ Assert.Equal("esp_terminal_failure", failed.Parameters!["reason"]);
+ }
+
+ [Fact]
+ public void DeadlineFired_LegacyDeadlineWithoutBaselines_FailsAsBefore()
+ {
+ // Rollout-safety: a window armed by a pre-fix agent (recovered snapshot) carries no
+ // baseline keys. The progress guard must report no progress — the fire resolves
+ // with the pre-fix semantics instead of throwing or re-arming forever.
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine);
+ state = ArmEspExitWindow(engine, state, ordinal: 50);
+
+ var builder = state.ToBuilder();
+ builder.CancelDeadline(DeadlineNames.AdvisoryCompletion);
+ builder.AddDeadline(new ActiveDeadline(
+ name: DeadlineNames.AdvisoryCompletion,
+ dueAtUtc: T0.AddMinutes(35.1),
+ firesSignalKind: DecisionSignalKind.DeadlineFired,
+ firesPayload: new Dictionary
+ {
+ [SignalPayloadKeys.Deadline] = DeadlineNames.AdvisoryCompletion,
+ }));
+ state = builder.Build();
+ // App progress that WOULD re-arm if the baselines existed.
+ state = engine.Reduce(state, AppInstallCompleted(60, T0.AddMinutes(20), "app-x")).NewState;
+
+ var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(35.1), DeadlineNames.AdvisoryCompletion));
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+ Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
+ }
+
+ // ==================================================== session-1924092e replay ====
+
+ [Fact]
+ public void Session1924092e_HandoffExitThenReboot_ActiveInstalls_CompletesInsteadOfFailing()
+ {
+ // The field session end-to-end: premature IME AccountSetup line → handoff exit
+ // arms the window → reboot cancels it → real user desktop → apps actively
+ // installing → a stray recovered-timer fire dead-ends → AccountSetup provisioning
+ // completes → the session finishes Completed. Pre-fix, the recovered deadline
+ // fired at 12:35:08 and failed the session mid-install.
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine);
+ state = ArmEspExitWindow(engine, state, ordinal: 50);
+
+ // AutoLogon restart chain (12:20 / 12:23 in the field session).
+ state = engine.Reduce(state, MakeSignal(
+ 60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(19),
+ new Dictionary { ["previousExitType"] = "reboot_kill" })).NewState;
+ Assert.Null(FindDeadline(state, DeadlineNames.AdvisoryCompletion));
+
+ // Real user signs in, Account-Setup ESP resumes visibly.
+ state = engine.Reduce(state, MakeSignal(70, DecisionSignalKind.DesktopArrived, T0.AddMinutes(23))).NewState;
+ state = engine.Reduce(state, MakeSignal(
+ 80, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(24),
+ new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
+ state = engine.Reduce(state, AppInstallCompleted(90, T0.AddMinutes(27), "app-winrar")).NewState;
+ state = engine.Reduce(state, AppInstallCompleted(95, T0.AddMinutes(28), "app-lansweeper")).NewState;
+ Assert.Null(state.Outcome);
+
+ // The pre-reboot wall-clock timer can still fire after recovery (state says
+ // cancelled, but the scheduler re-armed it from the recovered snapshot before the
+ // reboot signal was processed). The stale guard must dead-end it — NOT fail.
+ var strayFire = engine.Reduce(state, DeadlineFired(100, T0.AddMinutes(33), DeadlineNames.AdvisoryCompletion));
+ Assert.False(strayFire.Transition.Taken);
+ Assert.Equal("advisory_completion_stale_deadline_not_armed", strayFire.Transition.DeadEndReason);
+ Assert.Null(strayFire.NewState.Outcome);
+
+ // Account-Setup ESP finishes for real → normal completion path.
+ state = engine.Reduce(strayFire.NewState, MakeSignal(
+ 110, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(40))).NewState;
+ Assert.Equal(SessionStage.Finalizing, state.Stage);
+ var final = engine.Reduce(state, DeadlineFired(120, T0.AddMinutes(40).AddSeconds(5), DeadlineNames.FinalizingGrace));
+ Assert.Equal(SessionStage.Completed, final.NewState.Stage);
+ Assert.Equal(SessionOutcome.EnrollmentComplete, final.NewState.Outcome);
+ SingleTimelineEffect(final, "enrollment_complete");
+ }
+
+ [Fact]
+ public void RebootCancel_LaterGenuineGuardBlockedExit_RearmsFreshWindow()
+ {
+ // After the reboot cancel, the arming site's fire-once guard must pass again: a
+ // later genuine guard-blocked post-AccountSetup exit re-arms a fresh window so
+ // the 1ec8f4c6 dead-end closure is not lost for the rest of the session.
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine);
+ state = ArmEspExitWindow(engine, state, ordinal: 50);
+ state = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(19))).NewState;
+ Assert.Null(FindDeadline(state, DeadlineNames.AdvisoryCompletion));
+
+ var exitAt = T0.AddMinutes(30);
+ var step = engine.Reduce(state, MakeSignal(70, DecisionSignalKind.EspExiting, exitAt));
+
+ var rearmed = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
+ Assert.NotNull(rearmed);
+ Assert.Equal(exitAt.AddMinutes(30), rearmed!.DueAtUtc);
+ }
+
+ // ================================================== serialization round-trip ====
+
+ [Fact]
+ public void StateSerializer_Roundtrip_PreservesArmingBaselinesOnDeadlinePayload()
+ {
+ // The progress baselines live on the deadline's FiresPayload and MUST survive the
+ // snapshot round-trip — recovery after a crash mid-window otherwise loses the
+ // ability to tell an idle dead-end from a progressing enrollment.
+ var engine = new DecisionEngine();
+ var state = SetupClassicAccountSetupSession(engine);
+ state = ArmEspExitWindow(engine, state, ordinal: 50);
+
+ var roundtripped = StateSerializer.Deserialize(StateSerializer.Serialize(state));
+
+ var deadline = FindDeadline(roundtripped, DeadlineNames.AdvisoryCompletion);
+ Assert.NotNull(deadline);
+ Assert.NotNull(deadline!.FiresPayload);
+ Assert.Equal("50", deadline.FiresPayload!["armSignalOrdinal"]);
+ Assert.Equal("0", deadline.FiresPayload!["armAppTerminalCount"]);
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs
index ff06530a..e35e413a 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs
@@ -56,7 +56,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// fires, same dead-end). Without it the session would idle until the max-lifetime
/// watchdog with no terminal verdict. When this deadline fires the engine resolves the
/// session either way: Completed when the real-user completion conjunction holds
- /// (Desktop + Hello + genuine IME user-session evidence), Failed otherwise.
+ /// (Desktop + Hello + genuine IME user-session evidence), Failed otherwise — except
+ /// that the esp-exit variant first checks two false-positive guards (session 1924092e,
+ /// 2026-07-10: a pre-sign-in IME AccountSetup line let a Device→Account handoff exit
+ /// arm the window, which then failed a live enrollment mid-install): a reboot while
+ /// armed cancels the window, and enforcement progress since arming re-arms it instead
+ /// of failing.
///
public const string AdvisoryCompletion = "advisory_completion";
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
index 5f04eefc..fb92a65a 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
@@ -176,6 +176,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
// resolves the session either way. Fire-once: repeated post-AccountSetup exits
// do not re-base an already-armed window (unlike the advisory site, which
// deliberately replaces it — the failure is the fresher dead-end signal).
+ // Caveat (session 1924092e, 2026-07-10): AccountSetupEnteredUtc can be set by
+ // a pre-sign-in IME AccountSetup line, letting the Device→Account handoff
+ // exit arm this window falsely. A reboot cancels the window again
+ // (CancelEspExitVariantAdvisoryWindowOnReboot) — after which a later genuine
+ // guard-blocked exit re-arms here — and the fire handler re-arms instead of
+ // failing while enforcement is demonstrably progressing.
var exitEffects = Array.Empty();
if (state.AccountSetupEnteredUtc != null && !HasAdvisoryCompletionDeadline(state))
{
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
index 4a0fb4a5..7edc9145 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
using SharedConstants = AutopilotMonitor.Shared.Constants;
namespace AutopilotMonitor.DecisionCore.Engine
@@ -15,6 +17,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// EspAndHelloTracker after a reboot mid-ESP. The session's stage is preserved
/// (we were already somewhere in the ESP phase sequence), but the reboot fact is
/// recorded so downstream classifiers (WhiteGloveSealingClassifier) can factor it in.
+ /// A reboot also cancels an esp-exit-variant AdvisoryCompletion window — see
+ /// .
///
private DecisionStep HandleEspResumedV1(DecisionState state, DecisionSignal signal)
{
@@ -30,6 +34,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.SystemRebootUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
}
+ var cancelEffect = CancelEspExitVariantAdvisoryWindowOnReboot(state, builder);
+
var newState = builder.Build();
var transition = BuildTakenTransition(
before: state,
@@ -38,7 +44,51 @@ namespace AutopilotMonitor.DecisionCore.Engine
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.EspResumed));
- return new DecisionStep(newState, transition, Array.Empty());
+ return new DecisionStep(
+ newState,
+ transition,
+ cancelEffect != null ? new[] { cancelEffect } : Array.Empty());
+ }
+
+ ///
+ /// Session 1924092e (2026-07-10) — false-positive guard for the esp-exit-variant
+ /// window. The 1ec8f4c6 dead-end shape
+ /// is "the ESP page closed normally and then NOTHING happens on the device". A reboot
+ /// observed while that window is armed disproves the shape: the arming exit predates
+ /// the reboot, so it was a pre-reboot page close (Device→Account handoff, autologon
+ /// restart), not a final exit the user is now staring past. In session 1924092e the
+ /// IME logged an AccountSetup phase line pre-sign-in (defaultuser0/AutoLogon frame),
+ /// the handoff exit armed the window, and the deadline survived two reboots via state
+ /// recovery — then failed the session mid-AccountSetup while apps were actively
+ /// installing. Cancel the window; a later genuine guard-blocked post-AccountSetup
+ /// exit re-arms a fresh one (the arming site is fire-once only while armed).
+ ///
+ /// The advisory variant (
+ /// set) is deliberately NOT cancelled: its anchor is a real ESP terminal failure and
+ /// a reboot does not un-happen that failure — the window must still un-defang it.
+ ///
+ ///
+ /// Trade-off: in a true dead-end where the user manually reboots inside the window,
+ /// the verdict falls back to the max-lifetime watchdog (plus the backend timeout
+ /// reconcile) instead of the 30-min resolution — acceptable against failing live
+ /// enrollments, which is a trust-destroying false positive.
+ ///
+ /// Returns the CancelDeadline effect (and cancels on ),
+ /// or null when no esp-exit-variant window is armed.
+ ///
+ private static DecisionEffect? CancelEspExitVariantAdvisoryWindowOnReboot(
+ DecisionState state,
+ DecisionStateBuilder builder)
+ {
+ if (state.EspAdvisoryFailureRecordedUtc != null || !HasAdvisoryCompletionDeadline(state))
+ {
+ return null;
+ }
+
+ builder.CancelDeadline(DeadlineNames.AdvisoryCompletion);
+ return new DecisionEffect(
+ DecisionEffectKind.CancelDeadline,
+ cancelDeadlineName: DeadlineNames.AdvisoryCompletion);
}
///
@@ -231,10 +281,26 @@ namespace AutopilotMonitor.DecisionCore.Engine
///
private static readonly TimeSpan s_advisoryCompletionWindow = TimeSpan.FromMinutes(30);
+ ///
+ /// Arming-time baseline keys carried on the AdvisoryCompletion deadline's
+ /// (session 1924092e enforcement-progress
+ /// guard). The baselines live on the deadline — not on
+ /// facts — because they are meaningful only relative to a specific arming: replay
+ /// reconstructs them deterministically and the snapshot serializer round-trips the
+ /// payload with the deadline. Deadlines armed by pre-fix agents lack the keys; the
+ /// progress guard then reports no progress and the fire resolves exactly as before.
+ ///
+ private const string AdvisoryArmOrdinalKey = "armSignalOrdinal";
+
+ ///
+ private const string AdvisoryArmAppTerminalCountKey = "armAppTerminalCount";
+
///
/// Build the resolution deadline for
/// either arming site. Floored at AgentBootUtc so a replayed signal can't fire it
- /// immediately at boot.
+ /// immediately at boot. Carries the arming-time enforcement baselines so
+ /// can tell an idle dead-end from a
+ /// still-progressing enrollment at fire time.
///
internal static ActiveDeadline BuildAdvisoryCompletionDeadline(DecisionState state, DecisionSignal signal) =>
new ActiveDeadline(
@@ -244,15 +310,64 @@ namespace AutopilotMonitor.DecisionCore.Engine
firesPayload: new Dictionary
{
[SignalPayloadKeys.Deadline] = DeadlineNames.AdvisoryCompletion,
+ [AdvisoryArmOrdinalKey] =
+ signal.SessionSignalOrdinal.ToString(CultureInfo.InvariantCulture),
+ [AdvisoryArmAppTerminalCountKey] =
+ (state.AppInstallFacts.CompletedCount + state.AppInstallFacts.FailedCount)
+ .ToString(CultureInfo.InvariantCulture),
});
/// True when is armed in .
internal static bool HasAdvisoryCompletionDeadline(DecisionState state)
+ => FindAdvisoryCompletionDeadline(state) != null;
+
+ /// The armed deadline, or null.
+ internal static ActiveDeadline? FindAdvisoryCompletionDeadline(DecisionState state)
{
foreach (var d in state.Deadlines)
{
- if (d.Name == DeadlineNames.AdvisoryCompletion) return true;
+ if (d.Name == DeadlineNames.AdvisoryCompletion) return d;
}
+ return null;
+ }
+
+ ///
+ /// Session 1924092e (2026-07-10) — true when the device demonstrably kept enforcing
+ /// user-phase work AFTER the AdvisoryCompletion window was armed, i.e. the "page
+ /// closed and nothing happens" dead-end shape (1ec8f4c6) is disproven:
+ ///
+ /// - an app reached a terminal install state since arming
+ /// ( terminal count grew past the
+ /// arming-time baseline), or
+ /// - the ESP/IME re-asserted a user-phase enrollment phase
+ /// ( / )
+ /// with a signal ordinal newer than the arming.
+ ///
+ /// Missing baselines (deadline armed by a pre-fix agent, then recovered) report no
+ /// progress so legacy windows resolve with the pre-fix semantics.
+ ///
+ private static bool HasEnforcementProgressSinceArming(DecisionState state, ActiveDeadline armedDeadline)
+ {
+ var payload = armedDeadline.FiresPayload;
+ if (payload == null) return false;
+
+ if (payload.TryGetValue(AdvisoryArmAppTerminalCountKey, out var rawCount)
+ && int.TryParse(rawCount, NumberStyles.Integer, CultureInfo.InvariantCulture, out var armAppTerminalCount))
+ {
+ var appTerminalNow = state.AppInstallFacts.CompletedCount + state.AppInstallFacts.FailedCount;
+ if (appTerminalNow > armAppTerminalCount) return true;
+ }
+
+ if (payload.TryGetValue(AdvisoryArmOrdinalKey, out var rawOrdinal)
+ && long.TryParse(rawOrdinal, NumberStyles.Integer, CultureInfo.InvariantCulture, out var armOrdinal)
+ && state.CurrentEnrollmentPhase != null
+ && state.CurrentEnrollmentPhase.SourceSignalOrdinal > armOrdinal
+ && (state.CurrentEnrollmentPhase.Value == EnrollmentPhase.AccountSetup
+ || state.CurrentEnrollmentPhase.Value == EnrollmentPhase.AppsUser))
+ {
+ return true;
+ }
+
return false;
}
@@ -345,6 +460,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// deadline (DeadlineFired), which keeps the likely-stuck promotion OFF — the
/// ESP never gave up on those apps, the page just closed.
///
+ /// -
+ /// Re-arm (esp-exit variant only; session 1924092e, 2026-07-10) — before
+ /// failing, check : when apps kept
+ /// reaching terminal install states or the ESP re-asserted a user phase AFTER the
+ /// arming, the device is demonstrably still enrolling — failing it would be a false
+ /// positive on a live enrollment. Re-arm a fresh window (with updated baselines)
+ /// instead. Convergent: each re-arm demands NEW progress; a session that stalls
+ /// fails one window later, a session that finishes completes via the normal paths.
+ ///
///
/// Stale-fire guards mirror HandleRealmJoinTimeoutDeadlineFired: a fire without
/// any arming anchor (advisory recorded OR a post-AccountSetup final exit), without the
@@ -353,7 +477,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
///
private DecisionStep HandleAdvisoryCompletionDeadlineFired(DecisionState state, DecisionSignal signal)
{
- var deadlineStillArmed = HasAdvisoryCompletionDeadline(state);
+ var armedDeadline = FindAdvisoryCompletionDeadline(state);
+ var deadlineStillArmed = armedDeadline != null;
var hasAdvisoryAnchor = state.EspAdvisoryFailureRecordedUtc != null;
// Presence-only anchor (L5, delta review 2026-07-02). The esp-exit arming site only
@@ -362,9 +487,13 @@ namespace AutopilotMonitor.DecisionCore.Engine
// additional `EspFinalExitUtc >= AccountSetupEnteredUtc` comparison rejected fires
// whose arming exit carried a backfilled/out-of-order source timestamp — returning
// the session to the idle-until-max-lifetime dead-end this deadline exists to close.
- // An intermediate Device→Account exit cannot slip in here: it happens BEFORE
- // AccountSetup entry, never arms the window, and thus dead-ends on
- // `advisory_completion_stale_deadline_not_armed`.
+ // An intermediate Device→Account exit normally happens BEFORE AccountSetup entry,
+ // never arms the window, and dead-ends on
+ // `advisory_completion_stale_deadline_not_armed`. Session 1924092e (2026-07-10)
+ // showed it CAN slip in when the IME logs an AccountSetup phase line pre-sign-in
+ // (defaultuser0/AutoLogon frame) — that misfire is defused downstream by the
+ // reboot cancel (CancelEspExitVariantAdvisoryWindowOnReboot) and the
+ // enforcement-progress re-arm below, not by the anchor.
var hasEspExitAnchor = state.EspFinalExitUtc != null
&& state.AccountSetupEnteredUtc != null;
@@ -425,6 +554,53 @@ namespace AutopilotMonitor.DecisionCore.Engine
: null);
}
+ // Enforcement-progress guard (session 1924092e, 2026-07-10) — esp-exit variant
+ // only. The arming exit can be a misclassified handoff (IME logs an AccountSetup
+ // phase line pre-sign-in, then the Device→Account page close arms the window).
+ // When user-phase enforcement demonstrably progressed since arming, the dead-end
+ // shape is disproven: re-arm a fresh window with updated baselines instead of
+ // failing a live enrollment mid-install. The advisory variant is exempt — its
+ // anchor is a real ESP terminal failure that a busy IME does not un-happen.
+ if (!hasAdvisoryAnchor && HasEnforcementProgressSinceArming(state, armedDeadline!))
+ {
+ var rearmedDeadline = BuildAdvisoryCompletionDeadline(state, signal);
+ builder.AddDeadline(rearmedDeadline);
+
+ var rearmedState = builder.Build();
+ var rearmedTransition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: state.Stage,
+ nextStepIndex: nextStep,
+ trigger: $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}");
+
+ // Deliberately bypasses BuildCompletionWaitingEffect's fingerprint dedupe:
+ // the missing-prerequisites set is typically unchanged since arming, but the
+ // re-based resolution due-time is exactly what an operator debugging the
+ // session needs on the timeline. Bounded by construction — at most one event
+ // per 30-min window.
+ var rearmedEffects = new[]
+ {
+ new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: rearmedDeadline),
+ new DecisionEffect(
+ DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: new Dictionary
+ {
+ ["eventType"] = SharedConstants.EventTypes.CompletionWaiting,
+ ["source"] = "DecisionEngine",
+ ["severity"] = "Info",
+ ["immediateUpload"] = "false",
+ ["message"] = "Completion resolution window re-armed: enforcement still active after the ESP page exit",
+ ["missingPrerequisites"] = string.Join(",", BuildMissingCompletionPrerequisites(state)),
+ ["trigger"] = $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}:EnforcementActive",
+ ["stage"] = state.Stage.ToString(),
+ ["resolutionDeadlineDueAtUtc"] = rearmedDeadline.DueAtUtc.ToString("o"),
+ }),
+ };
+
+ return new DecisionStep(rearmedState, rearmedTransition, rearmedEffects);
+ }
+
// Conjunction not met — the session is failed. The two arming variants carry
// distinct context (see method doc): the advisory variant un-defangs the original
// ESP failure; the esp-exit variant never had an ESP failure, so it gets its own
@@ -638,7 +814,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// Handle . Records the fact so
/// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight, and emits
/// the system_reboot_detected timeline entry so the session timeline shows the
- /// split between pre-reboot and post-reboot collection.
+ /// split between pre-reboot and post-reboot collection. A reboot also cancels an
+ /// esp-exit-variant AdvisoryCompletion window — see
+ /// .
///
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
{
@@ -652,6 +830,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.SystemRebootUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
}
+ var cancelEffect = CancelEspExitVariantAdvisoryWindowOnReboot(state, builder);
+
var newState = builder.Build();
var transition = BuildTakenTransition(
before: state,
@@ -673,12 +853,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
timelineParams["lastBootUtc"] = lastBootUtc ?? string.Empty;
}
- var effects = new[]
- {
- new DecisionEffect(
- DecisionEffectKind.EmitEventTimelineEntry,
- parameters: timelineParams),
- };
+ var timelineEffect = new DecisionEffect(
+ DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: timelineParams);
+ var effects = cancelEffect != null
+ ? new[] { cancelEffect, timelineEffect }
+ : new[] { timelineEffect };
return new DecisionStep(newState, transition, effects);
}