fix(decision-core): rebase esp-exit advisory window on reboot instead of cancelling (session 7443317c)

The 9a107a19 reboot-cancel guard correctly stopped false
esp_exit_without_completion_evidence failures, but a pure cancel left the
post-AccountSetup dead-end zone with no resolution-capable deadline: session
7443317c (same tenant/shape as 1924092e, plus a mid-OOBE Windows-Update reboot
chain) tripped the session_parked_without_deadline liveness tripwire on a live
enrollment that later completed normally.

RebaseEspExitVariantAdvisoryWindowOnReboot now replaces the stale window with a
fresh 30-min one whose enforcement baselines (armSignalOrdinal /
armAppTerminalCount) anchor at the reboot signal, from both
HandleSystemRebootObservedV1 and HandleEspResumedV1:

- A live enrollment keeps disproving the dead-end at each fire via
  HasEnforcementProgressSinceArming (re-arm, never fail).
- A true dead-end where the user rebooted inside the window now resolves one
  window later (30-60 min) instead of idling to the 6-h max-lifetime watchdog -
  the 9a107a19 trade-off gap is closed.
- The parked tripwire needs no change: a resolution-capable deadline is armed
  throughout, so IsParkedWithoutDeadline stays false and the tripwire keeps its
  "every fire is a bug" charter.
- The rebase emits a completion_waiting with the new resolutionDeadlineDueAtUtc
  (trigger <Signal>:AdvisoryRebase), deliberately bypassing the fingerprint
  dedupe like the EnforcementActive re-arm site - bounded to one per reboot.

Tests: rebase with reboot-anchored baselines (both call sites), advisory
variant exempt, no-window no-op, true-dead-end-after-reboot fails at the
rebased due-time, 7443317c double-reboot live-install replay, fire-once keeps
the rebased window, 1924092e full replay updated (stray fire re-arms on
progress), engine-driven parked-tripwire regression.
DecisionCore 445/445, Agent.V2.Core 1754/1754.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-10 18:42:23 +02:00
parent c6b6e4504a
commit 76993478a5
4 changed files with 218 additions and 66 deletions
@@ -374,6 +374,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
AssertNoTripwireAfterDwell();
}
[Fact]
public void Reboot_after_guard_blocked_exit_rebases_window_and_never_parks()
{
// Session 7443317c (2026-07-10): the pre-rebase reboot CANCEL left the dead-end
// zone without a resolution-capable deadline and tripped this tripwire on a live
// enrollment. Regression: drive the REAL engine through the field shape (premature
// AccountSetup entry → guard-blocked exit arms the window → reboot) and prove the
// resulting state carries the rebased AdvisoryCompletion deadline — not parked.
var state = DecisionState.CreateInitial("S1", "T1", At);
foreach (var signal in new[]
{
TestSignals.Raw(1, DecisionSignalKind.SessionStarted, At),
TestSignals.Raw(2, DecisionSignalKind.EspConfigDetected, At.AddMinutes(1),
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.SkipUserEsp] = "false",
[SignalPayloadKeys.SkipDeviceEsp] = "false",
}),
TestSignals.Raw(3, DecisionSignalKind.EspPhaseChanged, At.AddMinutes(2),
payload: new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" }),
TestSignals.Raw(4, DecisionSignalKind.EspPhaseChanged, At.AddMinutes(5),
payload: new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" }),
TestSignals.Raw(5, DecisionSignalKind.EspExiting, At.AddMinutes(6)),
TestSignals.Raw(6, DecisionSignalKind.SystemRebootObserved, At.AddMinutes(20)),
})
{
state = _engine.Reduce(state, signal).NewState;
}
Assert.Contains(state.Deadlines, d => d.Name == DeadlineNames.AdvisoryCompletion);
var sut = Build(state);
var (step, signal2) = ReduceInformational(sut, 10);
sut.ApplyStep(step, signal2);
AssertNoTripwireAfterDwell();
}
[Fact]
public void Without_informational_post_wired_parked_state_is_tolerated()
{
@@ -19,9 +19,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
/// 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>Reboot rebase — <c>SystemRebootObserved</c> / <c>EspResumed</c> replace the
/// esp-exit-variant window with a fresh one whose baselines anchor at the reboot
/// (the arming exit predates a reboot ⇒ pre-reboot page close, not the 1ec8f4c6
/// dead-end; session 7443317c: a pure cancel parked the session without a
/// resolution deadline) but never touch 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>
@@ -113,39 +115,80 @@ namespace AutopilotMonitor.DecisionCore.Tests
return armed;
}
// ============================================================ reboot cancel ====
// ============================================================ reboot rebase ====
[Fact]
public void SystemReboot_CancelsEspExitVariantWindow_AndEmitsCancelEffect()
public void SystemReboot_RebasesEspExitVariantWindow_WithRebootAnchoredBaselines()
{
var engine = new DecisionEngine();
var state = SetupClassicAccountSetupSession(engine);
state = ArmEspExitWindow(engine, state);
var rebootAt = T0.AddMinutes(19);
var step = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(19),
60, DecisionSignalKind.SystemRebootObserved, rebootAt,
new Dictionary<string, string> { ["previousExitType"] = "reboot_kill" }));
Assert.Null(FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion));
// The stale window is replaced by a fresh one anchored at the reboot — the session
// is never parked without a resolution-capable deadline (7443317c tripwire).
var rebased = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(rebased);
Assert.Equal(rebootAt.AddMinutes(30), rebased!.DueAtUtc);
Assert.Equal("60", rebased.FiresPayload!["armSignalOrdinal"]);
Assert.Equal("0", rebased.FiresPayload!["armAppTerminalCount"]);
var cancel = step.Effects.Single(e => e.Kind == DecisionEffectKind.CancelDeadline);
Assert.Equal(DeadlineNames.AdvisoryCompletion, cancel.CancelDeadlineName);
var schedule = step.Effects.Single(e => e.Kind == DecisionEffectKind.ScheduleDeadline);
Assert.Equal(rebased.DueAtUtc, schedule.Deadline!.DueAtUtc);
// The re-based due-time is announced on the timeline (fingerprint bypass).
var waiting = SingleTimelineEffect(step, "completion_waiting");
Assert.Equal("SystemRebootObserved:AdvisoryRebase", waiting.Parameters!["trigger"]);
Assert.Equal(rebased.DueAtUtc.ToString("o"), waiting.Parameters!["resolutionDeadlineDueAtUtc"]);
// The reboot's own observability is untouched.
SingleTimelineEffect(step, "system_reboot_detected");
Assert.Null(step.NewState.Outcome);
}
[Fact]
public void EspResumed_CancelsEspExitVariantWindow()
public void EspResumed_RebasesEspExitVariantWindow()
{
var engine = new DecisionEngine();
var state = SetupClassicAccountSetupSession(engine);
state = ArmEspExitWindow(engine, state);
var step = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.EspResumed, T0.AddMinutes(19)));
var resumeAt = T0.AddMinutes(19);
var step = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.EspResumed, resumeAt));
Assert.Null(FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion));
var rebased = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(rebased);
Assert.Equal(resumeAt.AddMinutes(30), rebased!.DueAtUtc);
var cancel = step.Effects.Single(e => e.Kind == DecisionEffectKind.CancelDeadline);
Assert.Equal(DeadlineNames.AdvisoryCompletion, cancel.CancelDeadlineName);
var waiting = SingleTimelineEffect(step, "completion_waiting");
Assert.Equal("EspResumed:AdvisoryRebase", waiting.Parameters!["trigger"]);
}
[Fact]
public void RebasedWindow_TrueDeadEnd_NoProgressAfterReboot_FailsAtRebasedDue()
{
// The rebase must not swallow the genuine dead-end verdict: user reboots inside
// the window, ESP never comes back, nothing installs — the rebased window fires
// 30 min after the reboot and fails the session (instead of the pre-rebase
// behavior of idling to the max-lifetime watchdog after a pure cancel).
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;
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(49), 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]
@@ -173,10 +216,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.NotNull(after);
Assert.Equal(armed!.DueAtUtc, after!.DueAtUtc);
Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.CancelDeadline);
Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline);
}
[Fact]
public void SystemReboot_WithoutArmedWindow_EmitsNoCancelEffect()
public void SystemReboot_WithoutArmedWindow_EmitsNoRebaseEffects()
{
var engine = new DecisionEngine();
var state = SetupClassicAccountSetupSession(engine);
@@ -184,6 +228,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
var step = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(10)));
Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.CancelDeadline);
Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline);
SingleTimelineEffect(step, "system_reboot_detected");
}
@@ -321,19 +366,22 @@ namespace AutopilotMonitor.DecisionCore.Tests
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.
// arms the window → reboot re-bases it → real user desktop → apps actively
// installing → a recovered-timer fire re-arms on demonstrated progress →
// 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).
// AutoLogon restart chain (12:20 / 12:23 in the field session). The window is
// re-based at the reboot, never cancelled outright (7443317c parked tripwire).
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));
var rebased = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(rebased);
Assert.Equal(T0.AddMinutes(49), rebased!.DueAtUtc);
// Real user signs in, Account-Setup ESP resumes visibly.
state = engine.Reduce(state, MakeSignal(70, DecisionSignalKind.DesktopArrived, T0.AddMinutes(23))).NewState;
@@ -344,13 +392,12 @@ namespace AutopilotMonitor.DecisionCore.Tests
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.
// A stray pre-reboot wall-clock timer fires early: the rebased window IS armed,
// and enforcement progressed since the reboot baseline — re-arm, never 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);
Assert.Equal(state.Stage, strayFire.NewState.Stage);
Assert.NotNull(FindDeadline(strayFire.NewState, DeadlineNames.AdvisoryCompletion));
// Account-Setup ESP finishes for real → normal completion path.
state = engine.Reduce(strayFire.NewState, MakeSignal(
@@ -363,23 +410,62 @@ namespace AutopilotMonitor.DecisionCore.Tests
}
[Fact]
public void RebootCancel_LaterGenuineGuardBlockedExit_RearmsFreshWindow()
public void Session7443317c_DoubleRebootRebase_LiveInstalls_RearmsAtFire_NeverParksNorFails()
{
// 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.
// Field session 7443317c (2026-07-10, same tenant as 1924092e): handoff exit arms
// the window, a Windows-Update reboot chain (14:06 + 14:09) re-bases it twice,
// 32 apps install over ~65 min — each window fire sees new terminal states and
// re-arms — until the genuine ESP exit resolves the session. The session must
// never be parked (a resolution-capable deadline stays armed throughout) and
// never fail.
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));
state = engine.Reduce(state, MakeSignal(65, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(22))).NewState;
var rebased = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(rebased);
Assert.Equal(T0.AddMinutes(52), rebased!.DueAtUtc);
Assert.Equal("65", rebased.FiresPayload!["armSignalOrdinal"]);
var exitAt = T0.AddMinutes(30);
var step = engine.Reduce(state, MakeSignal(70, DecisionSignalKind.EspExiting, exitAt));
state = engine.Reduce(state, MakeSignal(70, DecisionSignalKind.DesktopArrived, T0.AddMinutes(26))).NewState;
state = engine.Reduce(state, AppInstallCompleted(80, T0.AddMinutes(30), "app-lansweeper")).NewState;
var rearmed = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
// Window fires at the rebased due-time mid-install → progress since the second
// reboot (one app terminal) → re-arm, session stays live.
var fire = engine.Reduce(state, DeadlineFired(90, T0.AddMinutes(52), DeadlineNames.AdvisoryCompletion));
Assert.Null(fire.NewState.Outcome);
var rearmed = FindDeadline(fire.NewState, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(rearmed);
Assert.Equal(exitAt.AddMinutes(30), rearmed!.DueAtUtc);
Assert.Equal(T0.AddMinutes(82), rearmed!.DueAtUtc);
// At no point between arming and completion was the session without a
// resolution-capable deadline (the 7443317c parked-tripwire condition).
state = engine.Reduce(fire.NewState, MakeSignal(
100, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(70))).NewState;
Assert.Equal(SessionStage.Finalizing, state.Stage);
}
[Fact]
public void RebootRebase_LaterGuardBlockedExit_KeepsRebasedWindow_FireOnce()
{
// After the reboot rebase a window is still armed, so the arming site's fire-once
// guard holds: a later guard-blocked post-AccountSetup exit must NOT re-base the
// window again (the rebased baselines stay authoritative until it fires).
var engine = new DecisionEngine();
var state = SetupClassicAccountSetupSession(engine);
state = ArmEspExitWindow(engine, state, ordinal: 50);
var rebootAt = T0.AddMinutes(19);
state = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.SystemRebootObserved, rebootAt)).NewState;
Assert.NotNull(FindDeadline(state, DeadlineNames.AdvisoryCompletion));
var step = engine.Reduce(state, MakeSignal(70, DecisionSignalKind.EspExiting, T0.AddMinutes(30)));
var kept = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(kept);
Assert.Equal(rebootAt.AddMinutes(30), kept!.DueAtUtc);
Assert.Equal("60", kept.FiresPayload!["armSignalOrdinal"]);
}
// ================================================== serialization round-trip ====
@@ -181,10 +181,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
// 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.
// exit arm this window falsely. A reboot re-bases the window with fresh
// enforcement baselines (RebaseEspExitVariantAdvisoryWindowOnReboot; session
// 7443317c — a pure cancel parked the session without a resolution deadline)
// 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))
{
@@ -17,8 +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"/>.
/// A reboot also re-bases an esp-exit-variant AdvisoryCompletion window — see
/// <see cref="RebaseEspExitVariantAdvisoryWindowOnReboot"/>.
/// </summary>
private DecisionStep HandleEspResumedV1(DecisionState state, DecisionSignal signal)
{
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
}
var cancelEffect = CancelEspExitVariantAdvisoryWindowOnReboot(state, builder);
var rebaseEffects = RebaseEspExitVariantAdvisoryWindowOnReboot(state, builder, signal);
var newState = builder.Build();
var transition = BuildTakenTransition(
@@ -44,10 +44,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.EspResumed));
return new DecisionStep(
newState,
transition,
cancelEffect != null ? new[] { cancelEffect } : Array.Empty<DecisionEffect>());
return new DecisionStep(newState, transition, rebaseEffects);
}
/// <summary>
@@ -60,35 +57,68 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// 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).
/// installing.
/// <para>
/// Session 7443317c (2026-07-10): a pure cancel left the post-AccountSetup dead-end
/// zone with no resolution-capable deadline and tripped the
/// <c>session_parked_without_deadline</c> liveness tripwire. So instead of cancelling,
/// RE-BASE: replace the stale window with a fresh one whose enforcement baselines
/// (<see cref="AdvisoryArmOrdinalKey"/> / <see cref="AdvisoryArmAppTerminalCountKey"/>)
/// anchor at the reboot signal. A live enrollment keeps disproving the dead-end at
/// each fire via <see cref="HasEnforcementProgressSinceArming"/>; a true dead-end
/// where the user rebooted inside the window now resolves one window later (30–60 min)
/// instead of idling to the max-lifetime watchdog.
/// </para>
/// <para>
/// The advisory variant (<see cref="DecisionState.EspAdvisoryFailureRecordedUtc"/>
/// set) is deliberately NOT cancelled: its anchor is a real ESP terminal failure and
/// set) is deliberately NOT touched: 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.
/// Returns the effects (cancel + schedule + a fingerprint-bypassing
/// <c>completion_waiting</c> carrying the re-based due-time, mirroring the
/// enforcement-progress re-arm site), or an empty array when no esp-exit-variant
/// window is armed. Mutates <paramref name="builder"/> accordingly.
/// </summary>
private static DecisionEffect? CancelEspExitVariantAdvisoryWindowOnReboot(
private DecisionEffect[] RebaseEspExitVariantAdvisoryWindowOnReboot(
DecisionState state,
DecisionStateBuilder builder)
DecisionStateBuilder builder,
DecisionSignal signal)
{
if (state.EspAdvisoryFailureRecordedUtc != null || !HasAdvisoryCompletionDeadline(state))
{
return null;
return Array.Empty<DecisionEffect>();
}
builder.CancelDeadline(DeadlineNames.AdvisoryCompletion);
return new DecisionEffect(
DecisionEffectKind.CancelDeadline,
cancelDeadlineName: DeadlineNames.AdvisoryCompletion);
var rebasedDeadline = BuildAdvisoryCompletionDeadline(state, signal);
builder.AddDeadline(rebasedDeadline);
return new[]
{
new DecisionEffect(
DecisionEffectKind.CancelDeadline,
cancelDeadlineName: DeadlineNames.AdvisoryCompletion),
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: rebasedDeadline),
// Deliberately bypasses BuildCompletionWaitingEffect's fingerprint dedupe:
// the missing-prerequisites set is unchanged by a reboot, but the re-based
// resolution due-time is what an operator debugging the session needs on the
// timeline. Bounded by construction — at most one event per observed reboot
// signal.
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: new Dictionary<string, string>
{
["eventType"] = SharedConstants.EventTypes.CompletionWaiting,
["source"] = "DecisionEngine",
["severity"] = "Info",
["immediateUpload"] = "false",
["message"] = "Completion resolution window re-based across reboot: the arming ESP exit predates the reboot",
["missingPrerequisites"] = string.Join(",", BuildMissingCompletionPrerequisites(state)),
["trigger"] = $"{signal.Kind}:AdvisoryRebase",
["stage"] = state.Stage.ToString(),
["resolutionDeadlineDueAtUtc"] = rebasedDeadline.DueAtUtc.ToString("o"),
}),
};
}
/// <summary>
@@ -492,7 +522,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
// `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
// reboot rebase (RebaseEspExitVariantAdvisoryWindowOnReboot) and the
// enforcement-progress re-arm below, not by the anchor.
var hasEspExitAnchor = state.EspFinalExitUtc != null
&& state.AccountSetupEnteredUtc != null;
@@ -812,9 +842,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. A reboot also cancels an
/// split between pre-reboot and post-reboot collection. A reboot also re-bases an
/// esp-exit-variant AdvisoryCompletion window — see
/// <see cref="CancelEspExitVariantAdvisoryWindowOnReboot"/>.
/// <see cref="RebaseEspExitVariantAdvisoryWindowOnReboot"/>.
/// </summary>
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
{
@@ -828,7 +858,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
}
var cancelEffect = CancelEspExitVariantAdvisoryWindowOnReboot(state, builder);
var rebaseEffects = RebaseEspExitVariantAdvisoryWindowOnReboot(state, builder, signal);
var newState = builder.Build();
var transition = BuildTakenTransition(
@@ -854,9 +884,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
var timelineEffect = new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: timelineParams);
var effects = cancelEffect != null
? new[] { cancelEffect, timelineEffect }
: new[] { timelineEffect };
var effects = AppendEffect(rebaseEffects, timelineEffect);
return new DecisionStep(newState, transition, effects);
}