mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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:
@@ -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
|
// PR1 ContinueAnyway-Defang (Session 4fa5a2d4, 2026-05-22) added
|
||||||
// `espAdvisoryFailureRecordedUtc` as the fire-once gate for downgraded ESP
|
// `espAdvisoryFailureRecordedUtc` as the fire-once gate for downgraded ESP
|
||||||
// terminal failures → 15.
|
// 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));
|
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
|
// `deviceSetupResolvedUtc` as the DeviceSetup-END anchor for the new
|
||||||
// deadline arm-point → 14. PR1 ContinueAnyway-Defang (Session 4fa5a2d4,
|
// deadline arm-point → 14. PR1 ContinueAnyway-Defang (Session 4fa5a2d4,
|
||||||
// 2026-05-22) added `espAdvisoryFailureRecordedUtc` as the fire-once gate
|
// 2026-05-22) added `espAdvisoryFailureRecordedUtc` as the fire-once gate
|
||||||
// for downgraded ESP terminal failures → 15. If this number ever changes,
|
// for downgraded ESP terminal failures → 15. Session 8bc1180f (2026-06-12)
|
||||||
// both the count expectation AND the actual snapshot output need to evolve
|
// added `imeUserSessionCompletedUtc` as the IME-evidence anchor for the
|
||||||
// in lockstep.
|
// AdvisoryCompletion conjunction → 16. If this number ever changes, both
|
||||||
Assert.Equal(15, expectedFactKeys.Count);
|
// 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 snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t"));
|
||||||
var facts = (Dictionary<string, object?>)snapshot["facts"]!;
|
var facts = (Dictionary<string, object?>)snapshot["facts"]!;
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
/// session can complete (Classic Finalizing or SelfDeploying-direct path).
|
/// session can complete (Classic Finalizing or SelfDeploying-direct path).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string RealmJoinTimeout = "realmjoin_timeout";
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -420,6 +420,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
/// Handle <see cref="DecisionSignalKind.ImeUserSessionCompleted"/>. Primarily a
|
/// Handle <see cref="DecisionSignalKind.ImeUserSessionCompleted"/>. Primarily a
|
||||||
/// <c>EnrollmentType</c>-hypothesis strengthener (IME's user-session-complete pattern
|
/// <c>EnrollmentType</c>-hypothesis strengthener (IME's user-session-complete pattern
|
||||||
/// is a strong UserDriven-v1 indicator), and records the matched pattern id.
|
/// 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>
|
/// </summary>
|
||||||
private DecisionStep HandleImeUserSessionCompletedV1(DecisionState state, DecisionSignal signal)
|
private DecisionStep HandleImeUserSessionCompletedV1(DecisionState state, DecisionSignal signal)
|
||||||
{
|
{
|
||||||
@@ -437,6 +445,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
builder.ImeMatchedPatternId = new SignalFact<string>(patternId!, signal.SessionSignalOrdinal);
|
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:
|
// Classic High-confidence promotion on user-session-complete. Codex follow-up #5:
|
||||||
// delegate to the updater for monotonicity.
|
// delegate to the updater for monotonicity.
|
||||||
builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(
|
builder.ScenarioProfile = EnrollmentScenarioProfileUpdater.ApplyImeUserSessionCompleted(
|
||||||
|
|||||||
@@ -215,6 +215,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
return new DecisionStep(newState, transition, effects);
|
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(
|
private DecisionStep BuildAdvisoryStep(
|
||||||
DecisionState state,
|
DecisionState state,
|
||||||
DecisionSignal signal,
|
DecisionSignal signal,
|
||||||
@@ -227,9 +237,28 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||||
.WithEspAdvisoryFailureRecorded(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
.WithEspAdvisoryFailureRecorded(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||||
|
|
||||||
// Stage / Outcome / Deadlines deliberately untouched — monitoring continues. The
|
// Stage / Outcome deliberately untouched — monitoring continues. The session's
|
||||||
// session's normal completion paths (Hello/Desktop, IME pattern, AccountSetup
|
// normal completion paths (Hello/Desktop, IME pattern, AccountSetup provisioning
|
||||||
// provisioning complete) still drive it.
|
// 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 newState = builder.Build();
|
||||||
|
|
||||||
var transition = BuildTakenTransition(
|
var transition = BuildTakenTransition(
|
||||||
@@ -241,6 +270,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
|
|
||||||
var effects = new[]
|
var effects = new[]
|
||||||
{
|
{
|
||||||
|
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: advisoryCompletion),
|
||||||
new DecisionEffect(
|
new DecisionEffect(
|
||||||
DecisionEffectKind.EmitEventTimelineEntry,
|
DecisionEffectKind.EmitEventTimelineEntry,
|
||||||
parameters: parameters,
|
parameters: parameters,
|
||||||
@@ -255,6 +285,147 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
return new DecisionStep(newState, transition, effects);
|
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(
|
private DecisionStep BuildFailedStep(
|
||||||
DecisionState state,
|
DecisionState state,
|
||||||
DecisionSignal signal,
|
DecisionSignal signal,
|
||||||
|
|||||||
@@ -360,6 +360,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
return HandleFinalizingGraceDeadlineFired(state, signal);
|
return HandleFinalizingGraceDeadlineFired(state, signal);
|
||||||
case DeadlineNames.RealmJoinTimeout:
|
case DeadlineNames.RealmJoinTimeout:
|
||||||
return HandleRealmJoinTimeoutDeadlineFired(state, signal);
|
return HandleRealmJoinTimeoutDeadlineFired(state, signal);
|
||||||
|
case DeadlineNames.AdvisoryCompletion:
|
||||||
|
return HandleAdvisoryCompletionDeadlineFired(state, signal);
|
||||||
default:
|
default:
|
||||||
// Deadline name not recognized in this sub-milestone. Cancel it from state
|
// Deadline name not recognized in this sub-milestone. Cancel it from state
|
||||||
// and record a neutral taken transition — M3.3+ adds handlers for
|
// 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);
|
timestamps["deviceSetupResolved"] = FormatUtc(state.DeviceSetupResolvedUtc.Value);
|
||||||
evidence["deviceSetupResolved"] = TimestampedEvidence(state.DeviceSetupResolvedUtc);
|
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.
|
// WhiteGlove Part-1 sealing observations — engine-internal, no UTC fact.
|
||||||
var obs = state.ScenarioObservations;
|
var obs = state.ScenarioObservations;
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
["helloPolicyEnabled"] = SerializeFact(state.HelloPolicyEnabled, v => (object)v),
|
["helloPolicyEnabled"] = SerializeFact(state.HelloPolicyEnabled, v => (object)v),
|
||||||
["lastFailureTrigger"] = SerializeFact(state.LastFailureTrigger, v => v),
|
["lastFailureTrigger"] = SerializeFact(state.LastFailureTrigger, v => v),
|
||||||
["espAdvisoryFailureRecordedUtc"] = SerializeFact(state.EspAdvisoryFailureRecordedUtc, FormatUtc),
|
["espAdvisoryFailureRecordedUtc"] = SerializeFact(state.EspAdvisoryFailureRecordedUtc, FormatUtc),
|
||||||
|
["imeUserSessionCompletedUtc"] = SerializeFact(state.ImeUserSessionCompletedUtc, FormatUtc),
|
||||||
};
|
};
|
||||||
|
|
||||||
var scenario = new Dictionary<string, object?>(StringComparer.Ordinal)
|
var scenario = new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
RealmJoinFacts? realmJoinFacts = null,
|
RealmJoinFacts? realmJoinFacts = null,
|
||||||
SignalFact<DateTime>? deviceSetupResolvedUtc = null,
|
SignalFact<DateTime>? deviceSetupResolvedUtc = null,
|
||||||
string? schemaVersion = null,
|
string? schemaVersion = null,
|
||||||
SignalFact<DateTime>? espAdvisoryFailureRecordedUtc = null)
|
SignalFact<DateTime>? espAdvisoryFailureRecordedUtc = null,
|
||||||
|
SignalFact<DateTime>? imeUserSessionCompletedUtc = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(sessionId))
|
if (string.IsNullOrEmpty(sessionId))
|
||||||
{
|
{
|
||||||
@@ -102,6 +103,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
DeviceSetupResolvedUtc = deviceSetupResolvedUtc;
|
DeviceSetupResolvedUtc = deviceSetupResolvedUtc;
|
||||||
SchemaVersion = schemaVersion ?? CurrentSchemaVersion;
|
SchemaVersion = schemaVersion ?? CurrentSchemaVersion;
|
||||||
EspAdvisoryFailureRecordedUtc = espAdvisoryFailureRecordedUtc;
|
EspAdvisoryFailureRecordedUtc = espAdvisoryFailureRecordedUtc;
|
||||||
|
ImeUserSessionCompletedUtc = imeUserSessionCompletedUtc;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string SessionId { get; }
|
public string SessionId { get; }
|
||||||
@@ -268,6 +270,22 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public SignalFact<DateTime>? EspAdvisoryFailureRecordedUtc { get; }
|
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; }
|
public string SchemaVersion { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
DeviceSetupResolvedUtc = source.DeviceSetupResolvedUtc;
|
DeviceSetupResolvedUtc = source.DeviceSetupResolvedUtc;
|
||||||
SchemaVersion = source.SchemaVersion;
|
SchemaVersion = source.SchemaVersion;
|
||||||
EspAdvisoryFailureRecordedUtc = source.EspAdvisoryFailureRecordedUtc;
|
EspAdvisoryFailureRecordedUtc = source.EspAdvisoryFailureRecordedUtc;
|
||||||
|
ImeUserSessionCompletedUtc = source.ImeUserSessionCompletedUtc;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string SessionId { get; set; }
|
public string SessionId { get; set; }
|
||||||
@@ -78,6 +79,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
public RealmJoinFacts RealmJoinFacts { get; set; } = RealmJoinFacts.Empty;
|
public RealmJoinFacts RealmJoinFacts { get; set; } = RealmJoinFacts.Empty;
|
||||||
public SignalFact<DateTime>? DeviceSetupResolvedUtc { get; set; }
|
public SignalFact<DateTime>? DeviceSetupResolvedUtc { get; set; }
|
||||||
public SignalFact<DateTime>? EspAdvisoryFailureRecordedUtc { get; set; }
|
public SignalFact<DateTime>? EspAdvisoryFailureRecordedUtc { get; set; }
|
||||||
|
public SignalFact<DateTime>? ImeUserSessionCompletedUtc { get; set; }
|
||||||
public string SchemaVersion { get; set; }
|
public string SchemaVersion { get; set; }
|
||||||
|
|
||||||
// ---------- fluent helpers for the most common reducer operations ----------
|
// ---------- fluent helpers for the most common reducer operations ----------
|
||||||
@@ -227,6 +229,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
|||||||
realmJoinFacts: RealmJoinFacts,
|
realmJoinFacts: RealmJoinFacts,
|
||||||
deviceSetupResolvedUtc: DeviceSetupResolvedUtc,
|
deviceSetupResolvedUtc: DeviceSetupResolvedUtc,
|
||||||
schemaVersion: SchemaVersion,
|
schemaVersion: SchemaVersion,
|
||||||
espAdvisoryFailureRecordedUtc: EspAdvisoryFailureRecordedUtc);
|
espAdvisoryFailureRecordedUtc: EspAdvisoryFailureRecordedUtc,
|
||||||
|
imeUserSessionCompletedUtc: ImeUserSessionCompletedUtc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user