mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -1 +1 @@
|
|||||||
1281
|
1291
|
||||||
|
|||||||
+406
@@ -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
|
/// 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
|
/// watchdog with no terminal verdict. When this deadline fires the engine resolves the
|
||||||
/// session either way: Completed when the real-user completion conjunction holds
|
/// 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>
|
/// </summary>
|
||||||
public const string AdvisoryCompletion = "advisory_completion";
|
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
|
// resolves the session either way. Fire-once: repeated post-AccountSetup exits
|
||||||
// do not re-base an already-armed window (unlike the advisory site, which
|
// do not re-base an already-armed window (unlike the advisory site, which
|
||||||
// deliberately replaces it — the failure is the fresher dead-end signal).
|
// 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>();
|
var exitEffects = Array.Empty<DecisionEffect>();
|
||||||
if (state.AccountSetupEnteredUtc != null && !HasAdvisoryCompletionDeadline(state))
|
if (state.AccountSetupEnteredUtc != null && !HasAdvisoryCompletionDeadline(state))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using AutopilotMonitor.DecisionCore.Signals;
|
using AutopilotMonitor.DecisionCore.Signals;
|
||||||
using AutopilotMonitor.DecisionCore.State;
|
using AutopilotMonitor.DecisionCore.State;
|
||||||
|
using AutopilotMonitor.Shared.Models;
|
||||||
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
using SharedConstants = AutopilotMonitor.Shared.Constants;
|
||||||
|
|
||||||
namespace AutopilotMonitor.DecisionCore.Engine
|
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
|
/// <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
|
/// (we were already somewhere in the ESP phase sequence), but the reboot fact is
|
||||||
/// recorded so downstream classifiers (WhiteGloveSealingClassifier) can factor it in.
|
/// recorded so downstream classifiers (WhiteGloveSealingClassifier) can factor it in.
|
||||||
|
/// A reboot also cancels an esp-exit-variant AdvisoryCompletion window — see
|
||||||
|
/// <see cref="CancelEspExitVariantAdvisoryWindowOnReboot"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private DecisionStep HandleEspResumedV1(DecisionState state, DecisionSignal signal)
|
private DecisionStep HandleEspResumedV1(DecisionState state, DecisionSignal signal)
|
||||||
{
|
{
|
||||||
@@ -30,6 +34,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cancelEffect = CancelEspExitVariantAdvisoryWindowOnReboot(state, builder);
|
||||||
|
|
||||||
var newState = builder.Build();
|
var newState = builder.Build();
|
||||||
var transition = BuildTakenTransition(
|
var transition = BuildTakenTransition(
|
||||||
before: state,
|
before: state,
|
||||||
@@ -38,7 +44,51 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
nextStepIndex: nextStep,
|
nextStepIndex: nextStep,
|
||||||
trigger: nameof(DecisionSignalKind.EspResumed));
|
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>
|
/// <summary>
|
||||||
@@ -231,10 +281,26 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly TimeSpan s_advisoryCompletionWindow = TimeSpan.FromMinutes(30);
|
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>
|
/// <summary>
|
||||||
/// Build the <see cref="DeadlineNames.AdvisoryCompletion"/> resolution deadline for
|
/// Build the <see cref="DeadlineNames.AdvisoryCompletion"/> resolution deadline for
|
||||||
/// either arming site. Floored at AgentBootUtc so a replayed signal can't fire it
|
/// 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>
|
/// </summary>
|
||||||
internal static ActiveDeadline BuildAdvisoryCompletionDeadline(DecisionState state, DecisionSignal signal) =>
|
internal static ActiveDeadline BuildAdvisoryCompletionDeadline(DecisionState state, DecisionSignal signal) =>
|
||||||
new ActiveDeadline(
|
new ActiveDeadline(
|
||||||
@@ -244,15 +310,64 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
firesPayload: new Dictionary<string, string>
|
firesPayload: new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
[SignalPayloadKeys.Deadline] = DeadlineNames.AdvisoryCompletion,
|
[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>
|
/// <summary>True when <see cref="DeadlineNames.AdvisoryCompletion"/> is armed in <paramref name="state"/>.</summary>
|
||||||
internal static bool HasAdvisoryCompletionDeadline(DecisionState state)
|
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)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +460,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
/// deadline (<c>DeadlineFired</c>), which keeps the likely-stuck promotion OFF — the
|
/// deadline (<c>DeadlineFired</c>), which keeps the likely-stuck promotion OFF — the
|
||||||
/// ESP never gave up on those apps, the page just closed.
|
/// ESP never gave up on those apps, the page just closed.
|
||||||
/// </item>
|
/// </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>
|
/// </list>
|
||||||
/// Stale-fire guards mirror <c>HandleRealmJoinTimeoutDeadlineFired</c>: a fire without
|
/// Stale-fire guards mirror <c>HandleRealmJoinTimeoutDeadlineFired</c>: a fire without
|
||||||
/// any arming anchor (advisory recorded OR a post-AccountSetup final exit), without the
|
/// any arming anchor (advisory recorded OR a post-AccountSetup final exit), without the
|
||||||
@@ -353,7 +477,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private DecisionStep HandleAdvisoryCompletionDeadlineFired(DecisionState state, DecisionSignal signal)
|
private DecisionStep HandleAdvisoryCompletionDeadlineFired(DecisionState state, DecisionSignal signal)
|
||||||
{
|
{
|
||||||
var deadlineStillArmed = HasAdvisoryCompletionDeadline(state);
|
var armedDeadline = FindAdvisoryCompletionDeadline(state);
|
||||||
|
var deadlineStillArmed = armedDeadline != null;
|
||||||
|
|
||||||
var hasAdvisoryAnchor = state.EspAdvisoryFailureRecordedUtc != null;
|
var hasAdvisoryAnchor = state.EspAdvisoryFailureRecordedUtc != null;
|
||||||
// Presence-only anchor (L5, delta review 2026-07-02). The esp-exit arming site only
|
// 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
|
// additional `EspFinalExitUtc >= AccountSetupEnteredUtc` comparison rejected fires
|
||||||
// whose arming exit carried a backfilled/out-of-order source timestamp — returning
|
// 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.
|
// 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
|
// An intermediate Device→Account exit normally happens BEFORE AccountSetup entry,
|
||||||
// AccountSetup entry, never arms the window, and thus dead-ends on
|
// never arms the window, and dead-ends on
|
||||||
// `advisory_completion_stale_deadline_not_armed`.
|
// `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
|
var hasEspExitAnchor = state.EspFinalExitUtc != null
|
||||||
&& state.AccountSetupEnteredUtc != null;
|
&& state.AccountSetupEnteredUtc != null;
|
||||||
|
|
||||||
@@ -425,6 +554,53 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
: null);
|
: 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
|
// Conjunction not met — the session is failed. The two arming variants carry
|
||||||
// distinct context (see method doc): the advisory variant un-defangs the original
|
// 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
|
// 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
|
/// Handle <see cref="DecisionSignalKind.SystemRebootObserved"/>. Records the fact so
|
||||||
/// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight, and emits
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
|
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
|
||||||
{
|
{
|
||||||
@@ -652,6 +830,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cancelEffect = CancelEspExitVariantAdvisoryWindowOnReboot(state, builder);
|
||||||
|
|
||||||
var newState = builder.Build();
|
var newState = builder.Build();
|
||||||
var transition = BuildTakenTransition(
|
var transition = BuildTakenTransition(
|
||||||
before: state,
|
before: state,
|
||||||
@@ -673,12 +853,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
timelineParams["lastBootUtc"] = lastBootUtc ?? string.Empty;
|
timelineParams["lastBootUtc"] = lastBootUtc ?? string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
var effects = new[]
|
var timelineEffect = new DecisionEffect(
|
||||||
{
|
DecisionEffectKind.EmitEventTimelineEntry,
|
||||||
new DecisionEffect(
|
parameters: timelineParams);
|
||||||
DecisionEffectKind.EmitEventTimelineEntry,
|
var effects = cancelEffect != null
|
||||||
parameters: timelineParams),
|
? new[] { cancelEffect, timelineEffect }
|
||||||
};
|
: new[] { timelineEffect };
|
||||||
|
|
||||||
return new DecisionStep(newState, transition, effects);
|
return new DecisionStep(newState, transition, effects);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user