diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/AdvisoryCompletionDeadlineTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AdvisoryCompletionDeadlineTests.cs
new file mode 100644
index 00000000..39af3680
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/AdvisoryCompletionDeadlineTests.cs
@@ -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
+{
+ ///
+ /// Session 8bc1180f (2026-06-12) — AdvisoryCompletion deadline + IME user-session
+ /// correlation gate. Coverage:
+ ///
+ /// - The advisory-defang path arms the advisory_completion deadline
+ /// (30 min, AgentBoot-floored) and emits a ScheduleDeadline effect.
+ /// - The enrichment-duplicate path does NOT re-arm / shift the deadline.
+ /// - 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.
+ /// - defaultuser0-ghost: an IME completion timestamped BEFORE AccountSetupEntered
+ /// must NOT satisfy the conjunction → Failed.
+ /// - Missing desktop / missing IME evidence → Failed with un-defanged
+ /// enrollment_failed + LastFailureTrigger=EspTerminalFailure (likely-stuck
+ /// promotion parity in EnrollmentTerminationHandler).
+ /// - Stale-fire guards (no advisory anchor / deadline retired / Finalizing in flight).
+ /// - ImeUserSessionCompletedUtc fact recording (set-once) + serialization
+ /// compatibility (roundtrip + legacy snapshot without the field).
+ ///
+ ///
+ 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? 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 { [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);
+
+ ///
+ /// 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.
+ ///
+ 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
+ {
+ [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 { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
+ }
+ state = engine.Reduce(state, MakeSignal(
+ 10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
+ new Dictionary { [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 { [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 { [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
+ {
+ ["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
+ {
+ ["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 { [SignalPayloadKeys.EspAllowContinueAnyway] = "true" })).NewState;
+ state = engine.Reduce(state, MakeSignal(
+ 20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
+ new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
+
+ var step = engine.Reduce(state, MakeSignal(
+ 50, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(27),
+ new Dictionary { ["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 { ["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
+ {
+ ["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 { [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 { [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 { [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(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(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"));
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs
index f1b44f27..05b9dfa1 100644
--- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs
@@ -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)snapshot["facts"]!;
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs
index 1c7787fc..3a41b1d1 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs
@@ -45,6 +45,18 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// session can complete (Classic Finalizing or SelfDeploying-direct path).
///
public const string RealmJoinTimeout = "realmjoin_timeout";
+
+ ///
+ /// Resolution window armed when an EspTerminalFailure is defanged to an
+ /// esp_failure_advisory (session 8bc1180f, 2026-06-12). The advisory keeps
+ /// monitoring alive, but when the failed subcategory is AccountSetup/Apps the
+ /// registry-driven AccountSetupProvisioningComplete 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.
+ ///
+ public const string AdvisoryCompletion = "advisory_completion";
}
///
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
index 4b6bc61e..31afe2ba 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs
@@ -420,6 +420,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// Handle . Primarily a
/// EnrollmentType-hypothesis strengthener (IME's user-session-complete pattern
/// is a strong UserDriven-v1 indicator), and records the matched pattern id.
+ ///
+ /// Additionally records
+ /// (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
+ /// defaultuser0, so the raw signal proves nothing about the real user. The
+ /// AdvisoryCompletion deadline handler consumes it lazily inside a correlation
+ /// conjunction (see HandleAdvisoryCompletionDeadlineFired).
+ ///
///
private DecisionStep HandleImeUserSessionCompletedV1(DecisionState state, DecisionSignal signal)
{
@@ -437,6 +445,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.ImeMatchedPatternId = new SignalFact(patternId!, signal.SessionSignalOrdinal);
}
+ if (state.ImeUserSessionCompletedUtc == null)
+ {
+ builder.ImeUserSessionCompletedUtc = new SignalFact(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(
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
index 806b3499..4f386b72 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
@@ -215,6 +215,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(newState, transition, effects);
}
+ ///
+ /// 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).
+ ///
+ 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
+ {
+ [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);
}
+ ///
+ /// 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):
+ ///
+ /// -
+ /// Complete — when the real-user completion conjunction holds:
+ /// is set (DAD-validated real user;
+ /// defaultuser0/SYSTEM are excluded at the detector), Hello is resolved or the policy
+ /// is explicitly disabled, AND
+ /// is at-or-after . 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 ShouldTransitionToAwaitingHello'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.
+ ///
+ /// -
+ /// Fail — otherwise. The ESP reported a terminal failure, the device had
+ /// 30 minutes to produce completion evidence and did not; un-defang the advisory.
+ /// is stamped with
+ /// EspTerminalFailure (not the deadline) so the
+ /// EnrollmentTerminationHandler 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.
+ ///
+ ///
+ /// Stale-fire guards mirror HandleRealmJoinTimeoutDeadlineFired: a fire without
+ /// the advisory anchor, without the deadline still armed, or while a Finalizing
+ /// transition is already in flight dead-ends as bookkeeping.
+ ///
+ 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());
+ }
+
+ 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(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
+ builder.HelloOutcome = new SignalFact("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,
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
index 56241703..aea55ebd 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
@@ -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
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs
index 91d13fff..be6efd14 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs
@@ -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;
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs
index 4c9dfc87..ad48fc3a 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs
@@ -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(StringComparer.Ordinal)
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
index 524ff56e..52195121 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs
@@ -61,7 +61,8 @@ namespace AutopilotMonitor.DecisionCore.State
RealmJoinFacts? realmJoinFacts = null,
SignalFact? deviceSetupResolvedUtc = null,
string? schemaVersion = null,
- SignalFact? espAdvisoryFailureRecordedUtc = null)
+ SignalFact? espAdvisoryFailureRecordedUtc = null,
+ SignalFact? 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
///
public SignalFact? EspAdvisoryFailureRecordedUtc { get; }
+ ///
+ /// Set when HandleImeUserSessionCompletedV1 observes the IME
+ /// IME-USER-SESSION-COMPLETED pattern (set-once; the first observation wins).
+ ///
+ /// On its own this fact is deliberately weak evidence: the IME "user session" can run
+ /// under defaultuser0 (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
+ /// (, which excludes defaultuser0/SYSTEM by construction)
+ /// AND a timestamp at-or-after (defaultuser0 IME
+ /// sessions live in the pre-AccountSetup OOBE frame). The
+ /// AdvisoryCompletion deadline handler evaluates exactly that conjunction.
+ ///
+ ///
+ public SignalFact? ImeUserSessionCompletedUtc { get; }
+
public string SchemaVersion { get; }
///
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
index a048e3f7..4f1eee69 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs
@@ -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? DeviceSetupResolvedUtc { get; set; }
public SignalFact? EspAdvisoryFailureRecordedUtc { get; set; }
+ public SignalFact? 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);
}
}