fix(decision-core): stop failing live enrollments via the esp-exit advisory window (session 1924092e)

The 1ec8f4c6 dead-end window (guard-blocked post-AccountSetup esp_exiting arms a
30-min advisory_completion resolution deadline) misfired on the Device->Account
handoff exit whenever the IME logged an AccountSetup phase line pre-sign-in
(defaultuser0/AutoLogon frame). The recovered deadline then failed sessions
mid-AccountSetup while apps were actively installing:
enrollment_failed: esp_exit_without_completion_evidence on a live enrollment.
41 of the 65 affected sessions since 2026-06-15 show reboots or app activity
after the arming exit - false positives.

Two guards, esp-exit variant only (the advisory variant keeps its real-failure
un-defang semantics):

- Reboot cancel: SystemRebootObserved / EspResumed cancel the armed window -
  an arming exit that predates a reboot was a pre-reboot page close, not the
  "page closed, nothing happens" dead-end. A later genuine guard-blocked exit
  re-arms a fresh window.
- Enforcement-progress re-arm: arming baselines (armSignalOrdinal,
  armAppTerminalCount) ride on the deadline's FiresPayload (replay-deterministic,
  snapshot-round-trips, no DecisionState schema change). At fire time, apps
  reaching terminal states or an ESP user-phase re-assert since arming re-arms
  the window instead of failing. Convergent: each re-arm demands new progress;
  legacy deadlines without baselines resolve with pre-fix semantics.

11 new tests incl. a full session-1924092e replay (handoff exit -> reboot ->
active installs -> stray recovered-timer fire dead-ends -> Completed) and pins
for the true dead-end verdict, the advisory variant, and baseline serialization.
DecisionCore 435/435, Agent.V2.Core 1725/1725.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-10 15:40:16 +02:00
parent cc3d23efe3
commit 9a107a1929
5 changed files with 613 additions and 16 deletions
@@ -1 +1 @@
1281
1291
@@ -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
{
/// <summary>
/// Session 1924092e (2026-07-10) — false-positive guards for the esp-exit-variant
/// <c>advisory_completion</c> 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) → <c>enrollment_failed:
/// esp_exit_without_completion_evidence</c> on a live enrollment. 65 sessions platform-wide
/// hit this between 2026-06-15 and 2026-07-10. Coverage:
/// <list type="bullet">
/// <item>Reboot cancel — <c>SystemRebootObserved</c> / <c>EspResumed</c> 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.</item>
/// <item>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).</item>
/// <item>Full session-1924092e replay: handoff exit → reboot → desktop → active installs
/// → stray recovered-timer fire dead-ends → provisioning-complete → Completed.</item>
/// <item>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.</item>
/// </list>
/// </summary>
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<string, string>? 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<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
private static DecisionSignal AppInstallCompleted(long ordinal, DateTime occurredAtUtc, string appId) =>
MakeSignal(ordinal, DecisionSignalKind.AppInstallCompleted, occurredAtUtc,
new Dictionary<string, string> { ["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);
/// <summary>
/// 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.
/// </summary>
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<string, string>
{
[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<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
state = engine.Reduce(state, MakeSignal(
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(5),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
return state;
}
/// <summary>Guard-blocked post-AccountSetup exit — arms the esp-exit-variant window.</summary>
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<string, string> { ["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<string, string>
{
["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<string, string> { [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<string, string>
{
["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<string, string>
{
[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<string, string> { ["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<string, string> { [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"]);
}
}
}
@@ -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.
/// </summary>
public const string AdvisoryCompletion = "advisory_completion";
}
@@ -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<DecisionEffect>();
if (state.AccountSetupEnteredUtc != null && !HasAdvisoryCompletionDeadline(state))
{
@@ -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
/// <c>EspAndHelloTracker</c> 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
/// <see cref="CancelEspExitVariantAdvisoryWindowOnReboot"/>.
/// </summary>
private DecisionStep HandleEspResumedV1(DecisionState state, DecisionSignal signal)
{
@@ -30,6 +34,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.SystemRebootUtc = new SignalFact<DateTime>(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<DecisionEffect>());
return new DecisionStep(
newState,
transition,
cancelEffect != null ? new[] { cancelEffect } : Array.Empty<DecisionEffect>());
}
/// <summary>
/// Session 1924092e (2026-07-10) — false-positive guard for the esp-exit-variant
/// <see cref="DeadlineNames.AdvisoryCompletion"/> 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).
/// <para>
/// The advisory variant (<see cref="DecisionState.EspAdvisoryFailureRecordedUtc"/>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// Returns the CancelDeadline effect (and cancels on <paramref name="builder"/>),
/// or null when no esp-exit-variant window is armed.
/// </summary>
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);
}
/// <summary>
@@ -231,10 +281,26 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// </summary>
private static readonly TimeSpan s_advisoryCompletionWindow = TimeSpan.FromMinutes(30);
/// <summary>
/// Arming-time baseline keys carried on the AdvisoryCompletion deadline's
/// <see cref="ActiveDeadline.FiresPayload"/> (session 1924092e enforcement-progress
/// guard). The baselines live on the deadline — not on <see cref="DecisionState"/>
/// 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.
/// </summary>
private const string AdvisoryArmOrdinalKey = "armSignalOrdinal";
/// <inheritdoc cref="AdvisoryArmOrdinalKey"/>
private const string AdvisoryArmAppTerminalCountKey = "armAppTerminalCount";
/// <summary>
/// Build the <see cref="DeadlineNames.AdvisoryCompletion"/> 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
/// <see cref="HasEnforcementProgressSinceArming"/> can tell an idle dead-end from a
/// still-progressing enrollment at fire time.
/// </summary>
internal static ActiveDeadline BuildAdvisoryCompletionDeadline(DecisionState state, DecisionSignal signal) =>
new ActiveDeadline(
@@ -244,15 +310,64 @@ namespace AutopilotMonitor.DecisionCore.Engine
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.AdvisoryCompletion,
[AdvisoryArmOrdinalKey] =
signal.SessionSignalOrdinal.ToString(CultureInfo.InvariantCulture),
[AdvisoryArmAppTerminalCountKey] =
(state.AppInstallFacts.CompletedCount + state.AppInstallFacts.FailedCount)
.ToString(CultureInfo.InvariantCulture),
});
/// <summary>True when <see cref="DeadlineNames.AdvisoryCompletion"/> is armed in <paramref name="state"/>.</summary>
internal static bool HasAdvisoryCompletionDeadline(DecisionState state)
=> FindAdvisoryCompletionDeadline(state) != null;
/// <summary>The armed <see cref="DeadlineNames.AdvisoryCompletion"/> deadline, or null.</summary>
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;
}
/// <summary>
/// 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:
/// <list type="bullet">
/// <item>an app reached a terminal install state since arming
/// (<see cref="DecisionState.AppInstallFacts"/> terminal count grew past the
/// arming-time baseline), or</item>
/// <item>the ESP/IME re-asserted a user-phase enrollment phase
/// (<see cref="EnrollmentPhase.AccountSetup"/> / <see cref="EnrollmentPhase.AppsUser"/>)
/// with a signal ordinal newer than the arming.</item>
/// </list>
/// Missing baselines (deadline armed by a pre-fix agent, then recovered) report no
/// progress so legacy windows resolve with the pre-fix semantics.
/// </summary>
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 (<c>DeadlineFired</c>), which keeps the likely-stuck promotion OFF — the
/// ESP never gave up on those apps, the page just closed.
/// </item>
/// <item>
/// <b>Re-arm</b> (esp-exit variant only; session 1924092e, 2026-07-10) — before
/// failing, check <see cref="HasEnforcementProgressSinceArming"/>: 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.
/// </item>
/// </list>
/// Stale-fire guards mirror <c>HandleRealmJoinTimeoutDeadlineFired</c>: a fire without
/// any arming anchor (advisory recorded OR a post-AccountSetup final exit), without the
@@ -353,7 +477,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// </summary>
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<string, string>
{
["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 <see cref="DecisionSignalKind.SystemRebootObserved"/>. Records the fact so
/// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight, and emits
/// the <c>system_reboot_detected</c> 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
/// <see cref="CancelEspExitVariantAdvisoryWindowOnReboot"/>.
/// </summary>
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
{
@@ -652,6 +830,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.SystemRebootUtc = new SignalFact<DateTime>(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);
}