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