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); }