fix(decision-core): resolve advisory-defanged ESP failures via AdvisoryCompletion deadline

An advisory-defanged EspTerminalFailure (ContinueAnyway + AccountSetup entered)
deliberately keeps the session non-terminal so the normal completion paths can
still win. Session 8bc1180f showed the dead-end variant: AccountSetup/Apps
failed (0x87d1041c), so AccountSetupProvisioningComplete can never arrive, the
IME settle probe was starved by a never-started user app, and Hello was
disabled - no completion path left. The session idled InProgress until the
max-lifetime watchdog, which by design issues no session verdict.

The advisory path now arms a 30-min AdvisoryCompletion deadline (AgentBoot-
floored, not re-armed by enrichment duplicates). On fire the engine resolves
the session either way:

- Completed (through Finalizing) when the real-user conjunction holds:
  desktop_arrived (DAD-validated, defaultuser0/SYSTEM excluded at the
  detector) AND Hello resolved-or-policy-disabled AND the new
  ImeUserSessionCompletedUtc fact at-or-after AccountSetupEnteredUtc. The
  IME timestamp gate kills defaultuser0 ghosts - OOBE/technician IME sessions
  complete in the pre-AccountSetup frame and can never satisfy it.
- Failed otherwise, un-defanging the advisory: enrollment_failed with
  reason=esp_terminal_failure and LastFailureTrigger=EspTerminalFailure so the
  EnrollmentTerminationHandler likely-stuck app promotion keeps firing.

ImeUserSessionCompletedUtc is an additive nullable DecisionState fact
(set-once in HandleImeUserSessionCompletedV1, serialized snapshot-compatible,
surfaced in the signal census as ime_user_session_completed).

DecisionCore.Tests 381/381 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-12 12:55:57 +02:00
parent 97397a9836
commit a77ad21e76
10 changed files with 747 additions and 10 deletions
@@ -0,0 +1,503 @@
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 8bc1180f (2026-06-12) — AdvisoryCompletion deadline + IME user-session
/// correlation gate. Coverage:
/// <list type="bullet">
/// <item>The advisory-defang path arms the <c>advisory_completion</c> deadline
/// (30 min, AgentBoot-floored) and emits a ScheduleDeadline effect.</item>
/// <item>The enrichment-duplicate path does NOT re-arm / shift the deadline.</item>
/// <item>Deadline fire with the full real-user conjunction (Desktop + Hello disabled +
/// IME-user-session-completed at-or-after AccountSetupEntered) → Finalizing →
/// Completed with synthesized HelloOutcome=Skipped.</item>
/// <item>defaultuser0-ghost: an IME completion timestamped BEFORE AccountSetupEntered
/// must NOT satisfy the conjunction → Failed.</item>
/// <item>Missing desktop / missing IME evidence → Failed with un-defanged
/// <c>enrollment_failed</c> + LastFailureTrigger=EspTerminalFailure (likely-stuck
/// promotion parity in EnrollmentTerminationHandler).</item>
/// <item>Stale-fire guards (no advisory anchor / deadline retired / Finalizing in flight).</item>
/// <item><c>ImeUserSessionCompletedUtc</c> fact recording (set-once) + serialization
/// compatibility (roundtrip + legacy snapshot without the field).</item>
/// </list>
/// </summary>
public sealed class AdvisoryCompletionDeadlineTests
{
private static readonly DateTime T0 = new DateTime(2026, 6, 12, 9, 0, 0, DateTimeKind.Utc);
private static DecisionSignal MakeSignal(
long ordinal,
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null,
string sourceOrigin = "test")
{
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: sourceOrigin,
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 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);
private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
state.Deadlines.FirstOrDefault(d => d.Name == name);
/// <summary>
/// Replays the session-8bc1180f shape up to (but excluding) the ESP terminal failure:
/// ContinueAnyway profile, Hello policy disabled, DeviceSetup → AccountSetup,
/// real-user desktop arrival (DAD-validated upstream), IME user-session completion.
/// Individual steps can be skipped to produce the negative variants.
/// </summary>
private static DecisionState SetupAdvisoryEligibleSession(
DecisionEngine engine,
bool helloPolicyDisabled = true,
bool desktopArrives = true,
bool imeUserSessionCompletes = true,
DateTime? imeUserSessionCompletedAt = null)
{
var state = DecisionState.CreateInitial("sess-8bc1180f", "tenant-8bc1180f", 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.EspSyncFailureTimeoutMinutes] = "60",
[SignalPayloadKeys.EspAllowContinueAnyway] = "true",
})).NewState;
if (helloPolicyDisabled)
{
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;
// Intermediate Device→Account esp_exiting (session-8bc1180f shape: 09:24:15, before
// AccountSetup at 09:24:33). Records EspFinalExitUtc without a stage transition —
// which is also what lets HandleAccountSetupProvisioningCompleteV1's deferred-promote
// path run in the post-terminal-guard tests below.
state = engine.Reduce(state, MakeSignal(
15, DecisionSignalKind.EspExiting, T0.AddMinutes(16.5))).NewState;
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
if (desktopArrives)
{
// Real-user desktop (defaultuser0/SYSTEM are excluded by the DAD before this
// signal is ever posted). With the strong AccountSetup gate unsatisfied the
// Hello-disabled fast-path defers — the fact is recorded, stage unchanged.
state = engine.Reduce(state, MakeSignal(
30, DecisionSignalKind.DesktopArrived, T0.AddMinutes(18))).NewState;
}
if (imeUserSessionCompletes)
{
state = engine.Reduce(state, MakeSignal(
40, DecisionSignalKind.ImeUserSessionCompleted, imeUserSessionCompletedAt ?? T0.AddMinutes(27),
new Dictionary<string, string> { [SignalPayloadKeys.ImePatternId] = "IME-USER-SESSION-COMPLETED" })).NewState;
}
return state;
}
private static DecisionState ApplyEspTerminalFailure(DecisionEngine engine, DecisionState state, long ordinal = 50) =>
engine.Reduce(state, MakeSignal(
ordinal, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(27.5),
new Dictionary<string, string>
{
["failureType"] = "Provisioning_AccountSetup_Apps_Failed",
["errorCode"] = "0x87d1041c",
["failedSubcategory"] = "Apps",
["category"] = "AccountSetup",
})).NewState;
// ===================================================== deadline arming ====
[Fact]
public void Advisory_ArmsAdvisoryCompletionDeadline_30MinFromSignal()
{
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine);
var step = engine.Reduce(state, MakeSignal(
50, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(27.5),
new Dictionary<string, string>
{
["failureType"] = "Provisioning_AccountSetup_Apps_Failed",
["errorCode"] = "0x87d1041c",
["failedSubcategory"] = "Apps",
["category"] = "AccountSetup",
}));
// Advisory recorded, session stays non-terminal.
Assert.NotNull(step.NewState.EspAdvisoryFailureRecordedUtc);
Assert.Null(step.NewState.Outcome);
// Deadline armed in state at signal-time + 30 min.
var deadline = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(deadline);
Assert.Equal(T0.AddMinutes(27.5).AddMinutes(30), deadline!.DueAtUtc);
// ScheduleDeadline effect emitted so the wall-clock scheduler arms the timer.
var scheduleEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.ScheduleDeadline);
Assert.Equal(DeadlineNames.AdvisoryCompletion, scheduleEffect.Deadline!.Name);
// The advisory timeline event is still emitted alongside.
SingleTimelineEffect(step, "esp_failure_advisory");
}
[Fact]
public void Advisory_DeadlineBase_FlooredAtAgentBoot_OnReplayedSignal()
{
// A replayed EspTerminalFailure carries a historical OccurredAtUtc. The deadline
// must be floored at AgentBootUtc so it can't fire the moment the agent boots.
var engine = new DecisionEngine();
var bootUtc = T0.AddHours(2);
var state = DecisionState.CreateInitial("sess-replay", "tenant-replay", bootUtc);
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.EspAllowContinueAnyway] = "true" })).NewState;
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
var step = engine.Reduce(state, MakeSignal(
50, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(27),
new Dictionary<string, string> { ["failedSubcategory"] = "Apps", ["category"] = "AccountSetup" }));
var deadline = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(deadline);
Assert.Equal(bootUtc.AddMinutes(30), deadline!.DueAtUtc);
}
[Fact]
public void Advisory_EnrichmentDuplicate_DoesNotReArmDeadline()
{
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine);
// First failure: sparse ShellCore shape — arms the deadline.
state = engine.Reduce(state, MakeSignal(
50, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(27.5),
new Dictionary<string, string> { ["reason"] = "esp_terminal_failure_account_setup" })).NewState;
var armed = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(armed);
// Enrichment duplicate 5 minutes later: registry detail arrives. The advisory anchor
// stays at the first signal — and so must the resolution deadline.
var step = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(32.5),
new Dictionary<string, string>
{
["failureType"] = "Provisioning_AccountSetup_Apps_Failed",
["errorCode"] = "0x87d1041c",
["failedSubcategory"] = "Apps",
["category"] = "AccountSetup",
}));
var afterEnrichment = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(afterEnrichment);
Assert.Equal(armed!.DueAtUtc, afterEnrichment!.DueAtUtc);
Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline);
}
// ============================================ deadline fired — completion ====
[Fact]
public void DeadlineFired_FullConjunction_CompletesThroughFinalizing()
{
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine);
state = ApplyEspTerminalFailure(engine, state);
var fireAt = T0.AddMinutes(57.5);
var step = engine.Reduce(state, DeadlineFired(70, fireAt, DeadlineNames.AdvisoryCompletion));
// Routes through the non-terminal Finalizing stage (FinalizingGrace pattern).
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
Assert.Null(step.NewState.Outcome);
Assert.Equal($"DeadlineFired:{DeadlineNames.AdvisoryCompletion}", step.Transition.Trigger);
// Hello synthesized as Skipped (policy disabled, never resolved live).
Assert.NotNull(step.NewState.HelloResolvedUtc);
Assert.Equal("Skipped", step.NewState.HelloOutcome!.Value);
// The fired deadline is retired; FinalizingGrace took its place.
Assert.Null(FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion));
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.FinalizingGrace));
// Drive FinalizingGrace → Completed + enrollment_complete on the wire.
var final = engine.Reduce(step.NewState, DeadlineFired(80, fireAt.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 DeadlineFired_HelloResolvedLive_KeepsRealHelloOutcome()
{
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine, helloPolicyDisabled: false);
// Hello resolves live before the failure (e.g. PIN enrollment finished).
state = engine.Reduce(state, MakeSignal(
45, DecisionSignalKind.HelloResolved, T0.AddMinutes(26),
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "Success" })).NewState;
// HelloResolved + DesktopArrived would normally complete — but the strong gate
// deferral keeps Classic sessions in EspAccountSetup; sanity-check we are not
// terminal yet before the failure arrives.
Assert.Null(state.Outcome);
state = ApplyEspTerminalFailure(engine, state);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
Assert.Equal("Success", step.NewState.HelloOutcome!.Value);
}
// =============================================== deadline fired — failed ====
[Fact]
public void DeadlineFired_ImeCompletedBeforeAccountSetup_DefaultUser0Ghost_Fails()
{
// The IME user-session completion happened BEFORE AccountSetup was entered —
// that is the defaultuser0/OOBE frame, not the real user's session. The
// conjunction must reject it even though desktop + hello are satisfied.
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(
engine,
imeUserSessionCompletedAt: T0.AddMinutes(10)); // AccountSetup entered at +17
state = ApplyEspTerminalFailure(engine, state);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
}
[Fact]
public void DeadlineFired_NoDesktop_FailsWithUnDefangedTerminalEvent()
{
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine, desktopArrives: false);
state = ApplyEspTerminalFailure(engine, state);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
Assert.Empty(step.NewState.Deadlines);
// LastFailureTrigger carries EspTerminalFailure (NOT the deadline) — parity with
// the direct failure path so EnrollmentTerminationHandler's likely-stuck app
// promotion discriminator fires.
Assert.Equal(nameof(DecisionSignalKind.EspTerminalFailure), step.NewState.LastFailureTrigger!.Value);
var failed = SingleTimelineEffect(step, "enrollment_failed");
Assert.Equal("esp_terminal_failure", failed.Parameters!["reason"]);
Assert.Equal("advisory_completion_window_expired_without_completion_evidence",
failed.Parameters!["advisoryReason"]);
// ContinueAnyway hints re-derived from observations.
Assert.Equal("true", failed.Parameters!["espAllowContinueAnyway"]);
Assert.Equal("true", failed.Parameters!["mayHaveContinuedAnyway"]);
Assert.Equal("60", failed.Parameters!["espSyncFailureTimeoutMinutes"]);
}
[Fact]
public void DeadlineFired_NoImeEvidence_Fails()
{
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine, imeUserSessionCompletes: false);
state = ApplyEspTerminalFailure(engine, state);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
}
// ======================================================= stale-fire guards ====
[Fact]
public void DeadlineFired_WithoutAdvisoryAnchor_DeadEnds()
{
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion));
Assert.False(step.Transition.Taken);
Assert.Equal("advisory_completion_without_advisory_anchor", step.Transition.DeadEndReason);
Assert.Empty(step.Effects);
Assert.Equal(state.Stage, step.NewState.Stage);
}
[Fact]
public void DeadlineFired_AfterNormalCompletion_DeadEndsViaPostTerminalGuard()
{
// The session completes via the normal AccountSetupProvisioningComplete path before
// the advisory deadline fires (e.g. user pressed "Try again" and the apps recovered).
// The stray timer fire after Completed must dead-end via the post-terminal guard.
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine);
state = ApplyEspTerminalFailure(engine, state);
// Strong gate arrives late → deferred-completion parity path → Finalizing → Completed.
state = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(40))).NewState;
Assert.Equal(SessionStage.Finalizing, state.Stage);
state = engine.Reduce(state, DeadlineFired(65, T0.AddMinutes(40).AddSeconds(5), DeadlineNames.FinalizingGrace)).NewState;
Assert.Equal(SessionStage.Completed, state.Stage);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(57.5), DeadlineNames.AdvisoryCompletion));
Assert.False(step.Transition.Taken);
Assert.Equal("signal_after_terminal:Completed", step.Transition.DeadEndReason);
Assert.Empty(step.Effects);
}
[Fact]
public void DeadlineFired_WhileFinalizingInFlight_DeadEnds()
{
// Race: a normal completion path entered Finalizing (5 s grace) and the advisory
// timer fires inside that window. Must not double-drive completion.
var engine = new DecisionEngine();
var state = SetupAdvisoryEligibleSession(engine);
state = ApplyEspTerminalFailure(engine, state);
state = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(40))).NewState;
Assert.Equal(SessionStage.Finalizing, state.Stage);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(40).AddSeconds(2), DeadlineNames.AdvisoryCompletion));
Assert.False(step.Transition.Taken);
Assert.Equal("advisory_completion_finalizing_already_in_flight", step.Transition.DeadEndReason);
Assert.Empty(step.Effects);
}
// ===================================================== fact recording ====
[Fact]
public void ImeUserSessionCompleted_RecordsFact_SetOnce()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("sess-fact", "tenant-fact", T0);
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = engine.Reduce(state, MakeSignal(
10, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(5),
new Dictionary<string, string> { [SignalPayloadKeys.ImePatternId] = "IME-USER-SESSION-COMPLETED" })).NewState;
Assert.NotNull(state.ImeUserSessionCompletedUtc);
Assert.Equal(T0.AddMinutes(5), state.ImeUserSessionCompletedUtc!.Value);
Assert.Equal(10, state.ImeUserSessionCompletedUtc!.SourceSignalOrdinal);
// A replayed second observation keeps the original anchor.
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(9),
new Dictionary<string, string> { [SignalPayloadKeys.ImePatternId] = "IME-USER-SESSION-COMPLETED" })).NewState;
Assert.Equal(T0.AddMinutes(5), state.ImeUserSessionCompletedUtc!.Value);
Assert.Equal(10, state.ImeUserSessionCompletedUtc!.SourceSignalOrdinal);
}
// ================================================== serialization compat ====
[Fact]
public void StateSerializer_Roundtrip_PreservesImeUserSessionCompletedUtc()
{
var builder = DecisionState.CreateInitial("sess-ser", "tenant-ser", T0).ToBuilder();
builder.ImeUserSessionCompletedUtc = new SignalFact<DateTime>(T0.AddMinutes(27), sourceSignalOrdinal: 40);
var state = builder.Build();
var roundtripped = StateSerializer.Deserialize(StateSerializer.Serialize(state));
Assert.NotNull(roundtripped.ImeUserSessionCompletedUtc);
Assert.Equal(T0.AddMinutes(27), roundtripped.ImeUserSessionCompletedUtc!.Value);
Assert.Equal(40, roundtripped.ImeUserSessionCompletedUtc!.SourceSignalOrdinal);
}
[Fact]
public void Deserialize_v4Snapshot_withoutImeUserSessionCompletedUtc_field_yieldsNullFact()
{
// Rollout-safety: ImeUserSessionCompletedUtc is an additive nullable field appended
// to the end of the DecisionState ctor. Persisted snapshots from versions before
// 2026-06-12 do not contain the property; rehydration must succeed with the fact null.
const string priorV4Json = @"{
""SessionId"": ""sess-rehydrate-ime"",
""TenantId"": ""tenant-rehydrate-ime"",
""Stage"": ""EspAccountSetup"",
""Outcome"": null,
""CurrentEnrollmentPhase"": null,
""DeviceSetupEnteredUtc"": null,
""AccountSetupEnteredUtc"": null,
""FinalizingEnteredUtc"": null,
""AccountSetupProvisioningSucceededUtc"": null,
""EspFinalExitUtc"": null,
""DesktopArrivedUtc"": null,
""HelloResolvedUtc"": null,
""SystemRebootUtc"": null,
""HelloOutcome"": null,
""ImeMatchedPatternId"": null,
""Deadlines"": [],
""LastAppliedSignalOrdinal"": 42,
""StepIndex"": 100,
""AppInstallFacts"": null,
""ScenarioProfile"": null,
""ScenarioObservations"": null,
""ClassifierOutcomes"": null,
""HelloPolicyEnabled"": null,
""AgentBootUtc"": ""2026-06-12T08:00:00Z"",
""LastFailureTrigger"": null,
""RealmJoinFacts"": null,
""DeviceSetupResolvedUtc"": null,
""EspAdvisoryFailureRecordedUtc"": null,
""SchemaVersion"": ""v4""
}";
var deserialized = StateSerializer.Deserialize(priorV4Json);
Assert.Null(deserialized.ImeUserSessionCompletedUtc);
Assert.Equal("sess-rehydrate-ime", deserialized.SessionId);
Assert.Equal(SessionStage.EspAccountSetup, deserialized.Stage);
}
// =============================================== audit-trail census ====
[Fact]
public void SignalCensus_IncludesImeUserSessionCompleted_WhenFactSet()
{
var builder = DecisionState.CreateInitial("sess-census", "tenant-census", T0).ToBuilder();
builder.ImeUserSessionCompletedUtc = new SignalFact<DateTime>(T0.AddMinutes(27), sourceSignalOrdinal: 40);
var census = DecisionStateSignalCensus.Build(builder.Build());
Assert.Contains("ime_user_session_completed", census.SignalsSeen);
Assert.True(census.SignalTimestamps.ContainsKey("imeUserSessionCompleted"));
Assert.True(census.SignalEvidence.ContainsKey("imeUserSessionCompleted"));
}
}
}
@@ -108,7 +108,9 @@ namespace AutopilotMonitor.DecisionCore.Tests
// PR1 ContinueAnyway-Defang (Session 4fa5a2d4, 2026-05-22) added
// `espAdvisoryFailureRecordedUtc` as the fire-once gate for downgraded ESP
// terminal failures → 15.
Assert.Equal(15, facts.Count);
// Session 8bc1180f (2026-06-12) added `imeUserSessionCompletedUtc` as the
// IME-evidence anchor for the AdvisoryCompletion conjunction → 16.
Assert.Equal(16, facts.Count);
Assert.All(facts.Values, v => Assert.Null(v));
}
@@ -224,10 +226,12 @@ namespace AutopilotMonitor.DecisionCore.Tests
// `deviceSetupResolvedUtc` as the DeviceSetup-END anchor for the new
// deadline arm-point → 14. PR1 ContinueAnyway-Defang (Session 4fa5a2d4,
// 2026-05-22) added `espAdvisoryFailureRecordedUtc` as the fire-once gate
// for downgraded ESP terminal failures → 15. If this number ever changes,
// both the count expectation AND the actual snapshot output need to evolve
// in lockstep.
Assert.Equal(15, expectedFactKeys.Count);
// for downgraded ESP terminal failures → 15. Session 8bc1180f (2026-06-12)
// added `imeUserSessionCompletedUtc` as the IME-evidence anchor for the
// AdvisoryCompletion conjunction → 16. If this number ever changes, both
// the count expectation AND the actual snapshot output need to evolve in
// lockstep.
Assert.Equal(16, expectedFactKeys.Count);
var snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t"));
var facts = (Dictionary<string, object?>)snapshot["facts"]!;
@@ -45,6 +45,18 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// session can complete (Classic Finalizing or SelfDeploying-direct path).
/// </summary>
public const string RealmJoinTimeout = "realmjoin_timeout";
/// <summary>
/// Resolution window armed when an <c>EspTerminalFailure</c> is defanged to an
/// <c>esp_failure_advisory</c> (session 8bc1180f, 2026-06-12). The advisory keeps
/// monitoring alive, but when the failed subcategory is AccountSetup/Apps the
/// registry-driven <c>AccountSetupProvisioningComplete</c> can never arrive and the
/// session would otherwise 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.
/// </summary>
public const string AdvisoryCompletion = "advisory_completion";
}
/// <summary>
@@ -420,6 +420,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// Handle <see cref="DecisionSignalKind.ImeUserSessionCompleted"/>. Primarily a
/// <c>EnrollmentType</c>-hypothesis strengthener (IME's user-session-complete pattern
/// is a strong UserDriven-v1 indicator), and records the matched pattern id.
/// <para>
/// Additionally records <see cref="DecisionState.ImeUserSessionCompletedUtc"/>
/// (set-once — the first observation wins, replays keep the original anchor). The fact
/// is NOT a completion trigger here: the IME "user session" can run under
/// <c>defaultuser0</c>, so the raw signal proves nothing about the real user. The
/// <c>AdvisoryCompletion</c> deadline handler consumes it lazily inside a correlation
/// conjunction (see <c>HandleAdvisoryCompletionDeadlineFired</c>).
/// </para>
/// </summary>
private DecisionStep HandleImeUserSessionCompletedV1(DecisionState state, DecisionSignal signal)
{
@@ -437,6 +445,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.ImeMatchedPatternId = new SignalFact<string>(patternId!, signal.SessionSignalOrdinal);
}
if (state.ImeUserSessionCompletedUtc == null)
{
builder.ImeUserSessionCompletedUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
}
// Classic High-confidence promotion on user-session-complete. Codex follow-up #5:
// delegate to the updater for monotonicity.
builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(
@@ -215,6 +215,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(newState, transition, effects);
}
/// <summary>
/// Resolution window for an advisory-defanged ESP terminal failure. 30 minutes is long
/// enough for the user to dismiss the failure screen ("Continue anyway"), reach the
/// desktop, and for IME's user-session processing to settle — and short enough to kill
/// the multi-hour "InProgress until max-lifetime" tail observed in session 8bc1180f
/// (Apps subcategory failed → AccountSetupProvisioningComplete impossible → no
/// completion path left, agent idled for the remaining ~5 h).
/// </summary>
private static readonly TimeSpan s_advisoryCompletionWindow = TimeSpan.FromMinutes(30);
private DecisionStep BuildAdvisoryStep(
DecisionState state,
DecisionSignal signal,
@@ -227,9 +237,28 @@ namespace AutopilotMonitor.DecisionCore.Engine
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.WithEspAdvisoryFailureRecorded(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
// Stage / Outcome / Deadlines deliberately untouched — monitoring continues. The
// session's normal completion paths (Hello/Desktop, IME pattern, AccountSetup
// provisioning complete) still drive it.
// Stage / Outcome deliberately untouched — monitoring continues. The session's
// normal completion paths (Hello/Desktop, IME pattern, AccountSetup provisioning
// complete) still drive it and win whenever they fire first.
//
// Session 8bc1180f (2026-06-12): additionally arm the AdvisoryCompletion deadline.
// When the failure is AccountSetup/Apps, every normal completion path can be dead
// (registry never flips, IME settle probe starved by a never-started app, Hello
// disabled) and the session would otherwise stay InProgress until the max-lifetime
// watchdog — which by design issues no session verdict. The deadline resolves the
// session either way (see HandleAdvisoryCompletionDeadlineFired). Floored at
// AgentBootUtc so a replayed EspTerminalFailure can't fire it immediately at boot.
var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_advisoryCompletionWindow);
var advisoryCompletion = new ActiveDeadline(
name: DeadlineNames.AdvisoryCompletion,
dueAtUtc: dueAtUtc,
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.AdvisoryCompletion,
});
builder.AddDeadline(advisoryCompletion);
var newState = builder.Build();
var transition = BuildTakenTransition(
@@ -241,6 +270,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
var effects = new[]
{
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: advisoryCompletion),
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: parameters,
@@ -255,6 +285,147 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(newState, transition, effects);
}
/// <summary>
/// <see cref="DeadlineNames.AdvisoryCompletion"/> fired — the resolution window after an
/// advisory-defanged ESP terminal failure expired without one of the normal completion
/// paths terminating the session. Resolve it now, either way (session 8bc1180f):
/// <list type="bullet">
/// <item>
/// <b>Complete</b> — when the real-user completion conjunction holds:
/// <see cref="DecisionState.DesktopArrivedUtc"/> is set (DAD-validated real user;
/// defaultuser0/SYSTEM are excluded at the detector), Hello is resolved or the policy
/// is explicitly disabled, AND <see cref="DecisionState.ImeUserSessionCompletedUtc"/>
/// is at-or-after <see cref="DecisionState.AccountSetupEnteredUtc"/>. The IME
/// timestamp gate kills defaultuser0 ghosts: an OOBE/technician IME session completes
/// in the pre-AccountSetup frame, so its timestamp can never satisfy the conjunction,
/// and in flows that never enter AccountSetup the anchor is missing entirely. The
/// conjunction deliberately REPLACES <c>ShouldTransitionToAwaitingHello</c>'s strong
/// registry gate — after an Apps-subcategory failure that gate is unsatisfiable by
/// construction, and IME's own user-session completion plus the real-user desktop is
/// the independent evidence that user-phase enforcement actually finished.
/// </item>
/// <item>
/// <b>Fail</b> — otherwise. The ESP reported a terminal failure, the device had
/// 30 minutes to produce completion evidence and did not; un-defang the advisory.
/// <see cref="DecisionState.LastFailureTrigger"/> is stamped with
/// <c>EspTerminalFailure</c> (not the deadline) so the
/// <c>EnrollmentTerminationHandler</c> likely-stuck app promotion discriminator
/// treats this exactly like a direct ESP terminal failure — the failure cause IS the
/// ESP failure, the deadline merely lifted the defang.
/// </item>
/// </list>
/// Stale-fire guards mirror <c>HandleRealmJoinTimeoutDeadlineFired</c>: a fire without
/// the advisory anchor, without the deadline still armed, or while a Finalizing
/// transition is already in flight dead-ends as bookkeeping.
/// </summary>
private DecisionStep HandleAdvisoryCompletionDeadlineFired(DecisionState state, DecisionSignal signal)
{
var deadlineStillArmed = false;
foreach (var d in state.Deadlines)
{
if (d.Name == DeadlineNames.AdvisoryCompletion) { deadlineStillArmed = true; break; }
}
string? staleReason = null;
if (state.EspAdvisoryFailureRecordedUtc == null) staleReason = "advisory_completion_without_advisory_anchor";
else if (!deadlineStillArmed) staleReason = "advisory_completion_stale_deadline_not_armed";
else if (state.Stage == SessionStage.Finalizing) staleReason = "advisory_completion_finalizing_already_in_flight";
if (staleReason != null)
{
var bookkept = BumpStepBookkeeping(state, signal);
var staleTransition = BuildDeadEndTransition(
state: state,
signal: signal,
nextStepIndex: bookkept.StepIndex,
trigger: $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}",
deadEndReason: staleReason);
return new DecisionStep(bookkept, staleTransition, Array.Empty<DecisionEffect>());
}
var nextStep = state.StepIndex + 1;
var builder = state.ToBuilder()
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.CancelDeadline(DeadlineNames.AdvisoryCompletion);
var desktopArrived = state.DesktopArrivedUtc != null;
var helloSatisfied = state.HelloResolvedUtc != null || state.HelloPolicyEnabled?.Value == false;
var imeUserSessionGenuine =
state.ImeUserSessionCompletedUtc != null
&& state.AccountSetupEnteredUtc != null
&& state.ImeUserSessionCompletedUtc.Value >= state.AccountSetupEnteredUtc.Value;
if (desktopArrived && helloSatisfied && imeUserSessionGenuine)
{
if (state.HelloResolvedUtc == null)
{
builder.HelloResolvedUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
builder.HelloOutcome = new SignalFact<string>("Skipped", signal.SessionSignalOrdinal);
}
// Dispose a still-armed HelloSafety scheduler timer (8b8d611d pattern) so it
// cannot fire post-Completion and re-enter the completion path.
var helloSafetyCancelEffect = BuildHelloSafetyCancelEffectIfArmed(state);
if (helloSafetyCancelEffect != null)
{
builder.CancelDeadline(DeadlineNames.HelloSafety);
}
return CompleteThroughFinalizingOrDefer(
state: state,
signal: signal,
preparedBuilder: builder,
nextStepIndex: nextStep,
trigger: $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}",
leadingEffects: helloSafetyCancelEffect != null
? new[] { helloSafetyCancelEffect }
: null);
}
// Conjunction not met — un-defang: the session is failed. Parameter bag mirrors the
// direct Failed path (ContinueAnyway hints from observations); the registry-derived
// failure context (failureType/errorCode/...) lived on the original signal and is
// already on the wire via the esp_failure_advisory event, so the terminal event
// carries the resolution reason instead.
var reason = "esp_terminal_failure";
var parameters = BuildEspFailureParameters(
signal: signal,
reason: reason,
advisoryPath: false,
observations: state.ScenarioObservations);
parameters["advisoryReason"] = "advisory_completion_window_expired_without_completion_evidence";
builder
.WithStage(SessionStage.Failed)
.WithOutcome(SessionOutcome.EnrollmentFailed)
.WithLastFailureTrigger(nameof(DecisionSignalKind.EspTerminalFailure), signal.SessionSignalOrdinal)
.ClearDeadlines();
var failedState = builder.Build();
var failedTransition = BuildTakenTransition(
before: state,
signal: signal,
toStage: SessionStage.Failed,
nextStepIndex: nextStep,
trigger: $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}");
var failedEffects = new[]
{
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: parameters,
typedPayload: BuildEspFailureAuditTrail(
postState: failedState,
decidedStage: SessionStage.Failed,
trigger: $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}",
failureReason: reason,
parameters: parameters)),
};
return new DecisionStep(failedState, failedTransition, failedEffects);
}
private DecisionStep BuildFailedStep(
DecisionState state,
DecisionSignal signal,
@@ -360,6 +360,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
return HandleFinalizingGraceDeadlineFired(state, signal);
case DeadlineNames.RealmJoinTimeout:
return HandleRealmJoinTimeoutDeadlineFired(state, signal);
case DeadlineNames.AdvisoryCompletion:
return HandleAdvisoryCompletionDeadlineFired(state, signal);
default:
// Deadline name not recognized in this sub-milestone. Cancel it from state
// and record a neutral taken transition — M3.3+ adds handlers for
@@ -100,6 +100,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
timestamps["deviceSetupResolved"] = FormatUtc(state.DeviceSetupResolvedUtc.Value);
evidence["deviceSetupResolved"] = TimestampedEvidence(state.DeviceSetupResolvedUtc);
}
// Session 8bc1180f — IME user-session completion anchor. Consumed by the
// AdvisoryCompletion deadline conjunction; surfaced in the census so terminal
// audit trails show whether the IME evidence was present (and its timestamp,
// for the >= AccountSetupEntered correlation check) when the session resolved.
if (state.ImeUserSessionCompletedUtc != null)
{
seen.Add("ime_user_session_completed");
timestamps["imeUserSessionCompleted"] = FormatUtc(state.ImeUserSessionCompletedUtc.Value);
evidence["imeUserSessionCompleted"] = TimestampedEvidence(state.ImeUserSessionCompletedUtc);
}
// WhiteGlove Part-1 sealing observations — engine-internal, no UTC fact.
var obs = state.ScenarioObservations;
@@ -63,6 +63,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
["helloPolicyEnabled"] = SerializeFact(state.HelloPolicyEnabled, v => (object)v),
["lastFailureTrigger"] = SerializeFact(state.LastFailureTrigger, v => v),
["espAdvisoryFailureRecordedUtc"] = SerializeFact(state.EspAdvisoryFailureRecordedUtc, FormatUtc),
["imeUserSessionCompletedUtc"] = SerializeFact(state.ImeUserSessionCompletedUtc, FormatUtc),
};
var scenario = new Dictionary<string, object?>(StringComparer.Ordinal)
@@ -61,7 +61,8 @@ namespace AutopilotMonitor.DecisionCore.State
RealmJoinFacts? realmJoinFacts = null,
SignalFact<DateTime>? deviceSetupResolvedUtc = null,
string? schemaVersion = null,
SignalFact<DateTime>? espAdvisoryFailureRecordedUtc = null)
SignalFact<DateTime>? espAdvisoryFailureRecordedUtc = null,
SignalFact<DateTime>? imeUserSessionCompletedUtc = null)
{
if (string.IsNullOrEmpty(sessionId))
{
@@ -102,6 +103,7 @@ namespace AutopilotMonitor.DecisionCore.State
DeviceSetupResolvedUtc = deviceSetupResolvedUtc;
SchemaVersion = schemaVersion ?? CurrentSchemaVersion;
EspAdvisoryFailureRecordedUtc = espAdvisoryFailureRecordedUtc;
ImeUserSessionCompletedUtc = imeUserSessionCompletedUtc;
}
public string SessionId { get; }
@@ -268,6 +270,22 @@ namespace AutopilotMonitor.DecisionCore.State
/// </summary>
public SignalFact<DateTime>? EspAdvisoryFailureRecordedUtc { get; }
/// <summary>
/// Set when <c>HandleImeUserSessionCompletedV1</c> observes the IME
/// <c>IME-USER-SESSION-COMPLETED</c> pattern (set-once; the first observation wins).
/// <para>
/// On its own this fact is deliberately weak evidence: the IME "user session" can run
/// under <c>defaultuser0</c> (OOBE auto-logon, WhiteGlove technician flow), where its
/// completion says nothing about the real user's enrollment. It becomes meaningful only
/// in conjunction with independent facts — a DAD-validated real-user desktop
/// (<see cref="DesktopArrivedUtc"/>, which excludes defaultuser0/SYSTEM by construction)
/// AND a timestamp at-or-after <see cref="AccountSetupEnteredUtc"/> (defaultuser0 IME
/// sessions live in the pre-AccountSetup OOBE frame). The
/// <c>AdvisoryCompletion</c> deadline handler evaluates exactly that conjunction.
/// </para>
/// </summary>
public SignalFact<DateTime>? ImeUserSessionCompletedUtc { get; }
public string SchemaVersion { get; }
/// <summary>
@@ -48,6 +48,7 @@ namespace AutopilotMonitor.DecisionCore.State
DeviceSetupResolvedUtc = source.DeviceSetupResolvedUtc;
SchemaVersion = source.SchemaVersion;
EspAdvisoryFailureRecordedUtc = source.EspAdvisoryFailureRecordedUtc;
ImeUserSessionCompletedUtc = source.ImeUserSessionCompletedUtc;
}
public string SessionId { get; set; }
@@ -78,6 +79,7 @@ namespace AutopilotMonitor.DecisionCore.State
public RealmJoinFacts RealmJoinFacts { get; set; } = RealmJoinFacts.Empty;
public SignalFact<DateTime>? DeviceSetupResolvedUtc { get; set; }
public SignalFact<DateTime>? EspAdvisoryFailureRecordedUtc { get; set; }
public SignalFact<DateTime>? ImeUserSessionCompletedUtc { get; set; }
public string SchemaVersion { get; set; }
// ---------- fluent helpers for the most common reducer operations ----------
@@ -227,6 +229,7 @@ namespace AutopilotMonitor.DecisionCore.State
realmJoinFacts: RealmJoinFacts,
deviceSetupResolvedUtc: DeviceSetupResolvedUtc,
schemaVersion: SchemaVersion,
espAdvisoryFailureRecordedUtc: EspAdvisoryFailureRecordedUtc);
espAdvisoryFailureRecordedUtc: EspAdvisoryFailureRecordedUtc,
imeUserSessionCompletedUtc: ImeUserSessionCompletedUtc);
}
}