fix(agent): complete enrollments with zero user-targeted apps instead of failing them

IME never writes its "Completed user session" line when the user has no
assigned Win32 apps - its zero-app branch returns before that statement
(ApplicationPoller.cs:713-721, identical in 1.80/1.83/1.97/1.104). So
ImeUserSessionCompletedUtc stayed null, every arm of
ShouldTransitionToAwaitingHello was unsatisfiable, and the 30-min
advisory_completion backstop resolved healthy enrollments as
esp_exit_without_completion_evidence. One tenant: 10 of 30 sessions in a week,
all false positives - DAD-validated desktop, Hello provisioned, RealmJoin still
installing. Every Succeeded session there carries ime_user_session_completed;
not one of the failed ones does.

IME-USER-SESSION-ZERO-APPS reads the line IME does write there and routes it
through the same OnUserSessionCompleted callback. That is IME's own verdict
rather than a weakened gate: the zero-app branch locks the ESP in with an empty
application list, and the sibling emit site returns ProvisioningComplete right
after logging it. Guarded to the AccountSetup phase so it cannot burn the
adapter fire-once flag on a pre-sign-in timestamp, and replayed on the phase
transition when the observation predates the phase marker. AreUserEspAppsSettled
accepts an empty app list only with that explicit observation, never on
emptiness alone.

Second, independent defect: advisory_completion (30 min) overran the RealmJoin
completion gate, which deliberately holds the session and carries its own
bounded resolution (60 min from detection, activity-extended, 4 h cap). One
session installed 25 RealmJoin packages inside the window, the last 2 min before
the failure, while completion_waiting still read "waiting on:
realmjoin_resolution". The handler now re-arms while that gate is closed and its
own deadline is still armed; convergence comes from the gate itself, which
always ends with an Outcome set.

ime_user_session_completed gains an evidence field (zero_user_apps_enumerated /
user_session_completed) - no new event type, so the Constants ->
resource-catalog -> shared-manifests triple stays untouched. The pattern ships
via the config API and reaches existing agents, where an unknown action is a
single Debug line; the behaviour needs the agent release.

Build 0 warnings. DecisionCore 558/558, V2.Core 2530/2530, Backend 4634/4634,
MCP event-types-drift 6/6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-25 12:27:42 +02:00
parent bf6850dedc
commit c91d8d4501
18 changed files with 885 additions and 7 deletions
@@ -0,0 +1,199 @@
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
{
/// <summary>
/// Sessions 81daa77f / 75d6ae8e (2026-08-25) — zero user-targeted apps starved every
/// user-phase completion probe. The IME zero-app branch returns before it writes the
/// "Completed user session" line, so a device whose user has no targeted Win32 apps never
/// produced the evidence <c>IsImeUserSessionGenuine</c> requires, and the AdvisoryCompletion
/// window failed the enrollment 30 minutes later — with the user at the desktop, Hello
/// provisioned and RealmJoin still installing.
/// <para>
/// IME-USER-SESSION-ZERO-APPS reads the line IME does write in that branch and routes it
/// through the same <see cref="ImeLogTracker.OnUserSessionCompleted"/> callback.
/// </para>
/// </summary>
public sealed class ImeLogTrackerZeroUserAppsTests
{
// Mirrors rules/ime-log-patterns/IME-USER-SESSION-ZERO-APPS.json. The JSON itself is
// pinned against a real IME line by BuiltInRulesTests in the backend suite.
private const string ZeroAppsPattern =
@"\[Win32App\] Get 0 apps for user session (?<sessionId>\d+), user id = (?<userId>[a-f0-9\-]+)";
private const string EspPhasePattern =
@"\[Win32App\] (?:In|The) EspPhase: (?<espPhase>\w+)";
private const string ZeroAppsLine =
"[Win32App] Get 0 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020";
private static ImeLogTracker BuildTracker(TempDirectory tmp) =>
new ImeLogTracker(
logFolder: tmp.Path,
patterns: new List<ImeLogPattern>
{
new ImeLogPattern
{
PatternId = "IME-USER-SESSION-ZERO-APPS",
Category = "always",
Pattern = ZeroAppsPattern,
Action = "userSessionZeroApps",
},
new ImeLogPattern
{
PatternId = "IME-ESP-PHASE",
Category = "always",
Pattern = EspPhasePattern,
Action = "espPhaseDetected",
},
},
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info));
[Fact]
public void ZeroApps_line_during_user_phase_fires_user_session_completed()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Equal(1, fired);
Assert.True(tracker.UserSessionZeroAppsObserved);
}
[Fact]
public void ZeroApps_line_before_user_phase_does_not_fire()
{
// Firing here would burn the adapter fire-once flag on a pre-sign-in timestamp that
// IsImeUserSessionGenuine (>= AccountSetupEnteredUtc) can never accept.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Equal(0, fired);
Assert.True(tracker.UserSessionZeroAppsObserved);
}
[Fact]
public void ZeroApps_observed_early_is_replayed_on_the_user_phase_transition()
{
// IME writes its check-in lines independently of when the agent first parses the ESP
// phase marker, so the evidence can legitimately arrive first. Dropping it would
// leave a zero-app device with no completion evidence at all.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Equal(0, fired);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
Assert.Equal(1, fired);
}
[Fact]
public void Repeated_user_phase_matches_do_not_replay_again()
{
// IME re-emits the phase string as it re-evaluates app sets. Only a real transition
// replays; the adapter fire-once flag is the outer guard, but the tracker must not
// spam the callback either.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
Assert.Equal(1, fired);
}
[Fact]
public void Nonzero_app_count_lines_do_not_match_the_pattern()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
var fired = 0;
tracker.OnUserSessionCompleted = () => fired++;
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(
"[Win32App] Get 3 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
tracker.ProcessLogMessageForTest(
"[Win32App] Get 10 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
Assert.Equal(0, fired);
Assert.False(tracker.UserSessionZeroAppsObserved);
}
[Fact]
public void AreUserEspAppsSettled_true_after_zero_apps_observation()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.True(tracker.AreUserEspAppsSettled());
}
[Fact]
public void AreUserEspAppsSettled_stays_false_on_an_empty_list_without_the_observation()
{
// Mutation proof for the branch above: an empty live list on its own still means
// "phase just cleared / apps not surfaced yet", never vacuously settled.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
Assert.False(tracker.AreUserEspAppsSettled());
}
[Fact]
public void AreUserEspAppsSettled_false_outside_the_user_phase_even_after_the_observation()
{
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: DeviceSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.True(tracker.UserSessionZeroAppsObserved);
Assert.False(tracker.AreUserEspAppsSettled());
}
[Fact]
public void GetPendingRequiredUserEspInstallApps_is_empty_for_a_zero_app_user_session()
{
// The adapter deferral probe must not park the signal when there is nothing to wait
// for — otherwise the zero-app path would starve exactly like the old one.
using var tmp = new TempDirectory();
var tracker = BuildTracker(tmp);
tracker.ProcessLogMessageForTest("[Win32App] In EspPhase: AccountSetup");
tracker.ProcessLogMessageForTest(ZeroAppsLine);
Assert.Empty(tracker.GetPendingRequiredUserEspInstallApps());
}
}
}
@@ -163,5 +163,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Single(f.DecisionSignals(DecisionSignalKind.ImeUserSessionCompleted));
Assert.Single(f.InfoEvents(SharedEventTypes.ImeUserSessionCompleted));
}
// ==================================================== zero-app evidence label ====
[Fact]
public void Zero_app_user_session_labels_the_evidence_on_signal_and_timeline()
{
// Sessions 81daa77f / 75d6ae8e: with no user-targeted apps IME never writes its
// "Completed user session" line at all, so the verdict rests on a different
// observation. Whoever reads the timeline later must be able to tell which.
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.SeedCurrentPhaseForTesting("AccountSetup");
f.Tracker.SeedUserSessionZeroAppsForTesting();
adapter.TriggerUserSessionCompletedFromTest();
Assert.Single(f.DecisionSignals(DecisionSignalKind.ImeUserSessionCompleted));
var evt = Assert.Single(f.InfoEvents(SharedEventTypes.ImeUserSessionCompleted));
Assert.Equal(
ImeLogTrackerAdapter.EvidenceZeroUserApps,
evt.Payload!["evidence"]);
}
[Fact]
public void Ordinary_user_session_completion_keeps_the_default_evidence_label()
{
// Mutation proof for the branch above.
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.SeedCurrentPhaseForTesting("AccountSetup");
AddApp(f.Tracker, "app-done", AppIntent.Install, AppInstallationState.Installed);
adapter.TriggerUserSessionCompletedFromTest();
var evt = Assert.Single(f.InfoEvents(SharedEventTypes.ImeUserSessionCompleted));
Assert.Equal(
ImeLogTrackerAdapter.EvidenceUserSessionCompleted,
evt.Payload!["evidence"]);
}
}
}
@@ -1042,6 +1042,56 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
/// </summary>
internal string LastEspPhaseDetected => _lastEspPhaseDetected;
/// <summary>
/// True while the user-scope ESP phase is the tracker's current phase. Every user-phase
/// probe and the zero-apps evidence gate on this — outside AccountSetup the IME lines
/// belong to the device phase or the pre-sign-in (defaultuser0) frame.
/// </summary>
private bool IsUserEspPhaseActive =>
string.Equals(_lastEspPhaseDetected, "AccountSetup", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// True once IME reported zero targeted Win32 apps for a user session while the user ESP
/// phase was active. Read by the adapter to label the evidence behind
/// <c>ime_user_session_completed</c>.
/// </summary>
internal bool UserSessionZeroAppsObserved => _userSessionZeroAppsObserved;
/// <summary>
/// IME-USER-SESSION-ZERO-APPS: IME enumerated the user session and found no targeted
/// Win32 apps. Its zero-app branch then <c>continue</c>s past the "Completed user
/// session" log statement, so this line is the only completion evidence such a device
/// ever produces — and IME itself treats the same condition as finished
/// (<c>ProviderResult.ProvisioningComplete</c> in the user check-in path, and an ESP
/// lock-in with an empty application list in the poller path).
/// <para>
/// Deliberately routed through <see cref="OnUserSessionCompleted"/> rather than a
/// separate callback: downstream this IS a completed user session, and the adapter's
/// pending-apps deferral plus its fire-once flag then apply unchanged.
/// </para>
/// <para>
/// Phase guard: firing before AccountSetup would burn the adapter's fire-once flag on a
/// pre-sign-in timestamp that <c>IsImeUserSessionGenuine</c> can never accept. The
/// observation is remembered instead and replayed by
/// <see cref="HandleEspPhaseDetected"/> on the transition into the user phase.
/// </para>
/// </summary>
private void HandleUserSessionZeroApps()
{
_userSessionZeroAppsObserved = true;
if (!IsUserEspPhaseActive)
{
_logger.Debug(
"ImeLogTracker: zero-app user session observed before the user ESP phase " +
$"(current phase: {_lastEspPhaseDetected ?? "(none)"}) - deferring until AccountSetup");
return;
}
_logger.Info("ImeLogTracker: user session enumerated with zero targeted apps - treating as user session completed");
OnUserSessionCompleted?.Invoke();
}
private void HandleEspPhaseDetected(string espPhaseString)
{
// Validate phase and get its ordinal for forward-only enforcement
@@ -1120,6 +1170,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
ActivatePatterns(logPhaseIsCurrentPhase);
OnEspPhaseChanged?.Invoke(espPhaseString);
// Replay a zero-app user session observed before this phase was known. IME logs its
// check-in lines independently of when the agent first parses the ESP phase marker,
// so the evidence can legitimately arrive first; without this the only completion
// evidence a zero-app device produces would be dropped on the floor.
if (isFirstOrChangedPhase && IsUserEspPhaseActive && _userSessionZeroAppsObserved)
{
_logger.Info("ImeLogTracker: replaying zero-app user session evidence observed before the user ESP phase");
OnUserSessionCompleted?.Invoke();
}
}
private void HandleEspTrackStatus(Match match)
@@ -387,6 +387,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
OnUserSessionCompleted?.Invoke();
break;
case "usersessionzeroapps":
// IME found no targeted user apps — its zero-app branch skips the
// "Completed user session" line, so this is the equivalent evidence.
HandleUserSessionZeroApps();
break;
case "updatestateinstalled":
if (!string.IsNullOrEmpty(id))
UpdateStateWithCallback(id, AppInstallationState.Installed);
@@ -129,6 +129,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private CancellationTokenSource _cts;
private bool _allAppsCompletedFired;
// Sticky: IME enumerated a user session and reported ZERO targeted Win32 apps
// (IME-USER-SESSION-ZERO-APPS). IME's zero-app branch returns before it writes the
// "Completed user session" line, so on a device with no user-targeted apps that line
// never appears and every user-phase completion probe would starve forever. This flag
// IS the user-phase completion evidence for that shape. Set only while the user ESP
// phase is active, or replayed on the transition into it.
private bool _userSessionZeroAppsObserved;
// State persistence: saves tracker state to disk so agent restart continues
// from the exact log position without re-parsing or re-building ignore lists.
private readonly ImeTrackerStatePersistence _statePersistence;
@@ -341,6 +349,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_lastEspPhaseDetected = phaseName;
}
/// <summary>
/// Test-only seam: marks the zero-targeted-apps user session as observed without driving
/// the IME-USER-SESSION-ZERO-APPS pattern through a log line. The pattern-to-flag path
/// itself is covered end-to-end by <c>ImeLogTrackerZeroUserAppsTests</c>.
/// </summary>
internal void SeedUserSessionZeroAppsForTesting()
{
_userSessionZeroAppsObserved = true;
}
/// <summary>
/// Returns a deduped union of phase-snapshotted apps plus the live
/// <see cref="PackageStates"/> (current phase). Live entries win on Id collision.
@@ -395,11 +413,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
try
{
if (!string.Equals(_lastEspPhaseDetected, "AccountSetup", StringComparison.OrdinalIgnoreCase))
if (!IsUserEspPhaseActive)
return false;
var apps = new List<AppPackageState>(_packageStates);
if (apps.Count == 0) return false;
// Zero user-targeted apps is the MOST settled state there is, but the
// conservative "at least one required app" rule below reads it as "not
// settled" and starves the AccountSetup synthesis. Only an explicit IME
// observation lifts that — never a merely empty list, which also means
// "phase just cleared" or "apps not surfaced yet".
if (apps.Count == 0)
return _userSessionZeroAppsObserved;
var sawRequired = false;
foreach (var pkg in apps)
@@ -746,6 +771,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_currentPhaseOrder = state.CurrentPhaseOrder;
_lastEspPhaseDetected = state.LastEspPhaseDetected;
_allAppsCompletedFired = state.AllAppsCompletedFired;
_userSessionZeroAppsObserved = state.UserSessionZeroAppsObserved;
_logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase;
// Restore seen app IDs
@@ -861,6 +887,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
CurrentPhaseOrder = _currentPhaseOrder,
LastEspPhaseDetected = _lastEspPhaseDetected,
AllAppsCompletedFired = _allAppsCompletedFired,
UserSessionZeroAppsObserved = _userSessionZeroAppsObserved,
LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase,
SeenAppIds = _seenAppIds.ToList(),
IgnoreList = _packageStates.IgnoreList.ToList(),
@@ -109,6 +109,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public int CurrentPhaseOrder { get; set; }
public string LastEspPhaseDetected { get; set; }
public bool AllAppsCompletedFired { get; set; }
/// <summary>
/// IME reported zero targeted Win32 apps for a user session. Absent in state files
/// written before this field existed — deserializing to false there is correct: the
/// tracker simply re-observes the line on the next IME check-in.
/// </summary>
public bool UserSessionZeroAppsObserved { get; set; }
public bool LogPhaseIsCurrentPhase { get; set; }
public List<string> SeenAppIds { get; set; }
public List<string> IgnoreList { get; set; }
@@ -56,6 +56,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
private const string SourceLabel = "ImeLogTracker";
/// <summary>Timeline label: IME wrote its "Completed user session" line and no required user-scope app was pending.</summary>
internal const string EvidenceUserSessionCompleted = "user_session_completed";
/// <summary>Timeline label: IME reported zero targeted apps for the user session, so it never wrote a
/// "Completed user session" line at all. The user phase had nothing to enforce.</summary>
internal const string EvidenceZeroUserApps = "zero_user_apps_enumerated";
private readonly ImeLogTracker _tracker;
private readonly ISignalIngressSink _ingress;
private readonly IClock _clock;
@@ -591,9 +598,24 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
// Which observation carried the completion. The zero-app variant matters to whoever
// reads the timeline later: the verdict then rests on IME reporting that this user
// had no targeted apps at all, not on watching enforcement finish.
var zeroApps = _tracker.UserSessionZeroAppsObserved;
var evidenceLabel = zeroApps ? EvidenceZeroUserApps : EvidenceUserSessionCompleted;
var summary = zeroApps
? "IME enumerated the user session with zero targeted apps"
: "IME user session completed (no required user-scope apps pending)";
var message = zeroApps
? "IME enumerated the user session with zero targeted apps - no user-phase enforcement to wait for"
: "IME user session completed";
var derivationInputs = new Dictionary<string, string>(StringComparer.Ordinal)
{
["detectionSource"] = "IME log pattern (UserSessionCompleted)",
["detectionSource"] = zeroApps
? "IME log pattern (UserSessionZeroApps)"
: "IME log pattern (UserSessionCompleted)",
["evidence"] = evidenceLabel,
};
TagDerivedTimestamp(derivationInputs, derivedFromClock, rawSourceTs);
@@ -604,12 +626,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "ime-log-tracker-v1",
summary: "IME user session completed (no required user-scope apps pending)",
summary: summary,
derivationInputs: derivationInputs));
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["detectedAt"] = now.ToString("o"),
["evidence"] = evidenceLabel,
};
var patternId = _tracker.LastMatchedPatternId;
if (!string.IsNullOrEmpty(patternId))
@@ -619,12 +642,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_post.Emit(
eventType: SharedEventTypes.ImeUserSessionCompleted,
source: SourceLabel,
message: "IME user session completed",
message: message,
data: data,
occurredAtUtc: now);
// PR3-D4: terminal-ish lifecycle marker — INFO so it's visible at the default level.
_logger?.Info($"ImeAdapter: user session completed -> posting ImeUserSessionCompleted (patternId={patternId ?? "(none)"})");
_logger?.Info($"ImeAdapter: user session completed ({evidenceLabel}) -> posting ImeUserSessionCompleted (patternId={patternId ?? "(none)"})");
}
/// <summary>
@@ -0,0 +1,242 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Sessions 81daa77f / 75d6ae8e (2026-08-25) — the 30-min <c>advisory_completion</c> window
/// overran a completion gate that was deliberately holding the session. The RealmJoin gate
/// blocks completion until phase 110 and carries its own bounded resolution (60 min from
/// detection, re-armed on deployment activity, hard-capped at 4 h), so on every device whose
/// first deployment outlives 30 minutes the enrollment was failed while the engine own
/// <c>completion_waiting</c> still read "waiting on: realmjoin_resolution". 75d6ae8e
/// installed 25 RealmJoin packages successfully inside the window, the last one 2 min before
/// the failure; the customer tenant hit this on 10 of 30 enrollments in one week.
/// <para>
/// The guard re-arms while the gate holds. Convergence comes from the gate itself: Resolved,
/// FirstDeploymentIncomplete and Timeout all set <c>RealmJoinFacts.Outcome</c>, which opens
/// <c>RealmJoinGateOpen</c> and stops the guard applying.
/// </para>
/// </summary>
public sealed class AdvisoryCompletionGateHoldingTests
{
private static readonly DateTime T0 = new DateTime(2026, 8, 25, 6, 10, 0, DateTimeKind.Utc);
private static DecisionSignal MakeSignal(
long ordinal,
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null)
{
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: "test",
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 ActiveDeadline? FindDeadline(DecisionState state, string name) =>
state.Deadlines.FirstOrDefault(d => d.Name == name);
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);
/// <summary>
/// Replays the shape both field sessions shared: Classic user-driven, full ESP, Hello
/// policy enabled, AccountSetup entered, then a guard-blocked post-AccountSetup ESP exit
/// (Shell-Core 62407, "Exiting page normally", errorCode 0) that arms the 30-min window.
/// The ESP registry froze at "1 of 5" and the device had zero user-targeted Intune apps,
/// so no arm of ShouldTransitionToAwaitingHello could ever open.
/// </summary>
private static DecisionState SetupEspExitDeadEnd(DecisionEngine engine)
{
var state = DecisionState.CreateInitial("sess-81daa77f", "tenant-81daa77f", 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.EspAllowContinueAnyway] = "false",
})).NewState;
state = engine.Reduce(state, MakeSignal(
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2.5),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
// Guard-blocked post-AccountSetup exit — arms advisory_completion at exit + 30 min.
state = engine.Reduce(state, MakeSignal(30, DecisionSignalKind.EspExiting, T0.AddMinutes(3))).NewState;
Assert.NotNull(FindDeadline(state, DeadlineNames.AdvisoryCompletion));
Assert.Null(state.Outcome);
return state;
}
/// <summary>Desktop (DAD-validated real user), RealmJoin detected, Hello provisioned.</summary>
private static DecisionState AddDesktopRealmJoinAndHello(DecisionEngine engine, DecisionState state)
{
state = engine.Reduce(state, MakeSignal(40, DecisionSignalKind.DesktopArrived, T0.AddMinutes(3.5))).NewState;
state = engine.Reduce(state, MakeSignal(
50, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(6),
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0" })).NewState;
state = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.HelloResolved, T0.AddMinutes(14),
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "Success" })).NewState;
return state;
}
// ==================================================== the gate-holding guard ====
[Fact]
public void DeadlineFired_WhileRealmJoinGateHolds_RearmsInsteadOfFailing()
{
var engine = new DecisionEngine();
var state = SetupEspExitDeadEnd(engine);
state = AddDesktopRealmJoinAndHello(engine, state);
// Preconditions: the gate is closed and owns an armed deadline of its own.
Assert.False(DecisionEngine.RealmJoinGateOpen(state));
Assert.NotNull(FindDeadline(state, DeadlineNames.RealmJoinTimeout));
var fireAt = T0.AddMinutes(33);
var step = engine.Reduce(state, DeadlineFired(70, fireAt, DeadlineNames.AdvisoryCompletion));
Assert.True(step.Transition.Taken);
Assert.Null(step.NewState.Outcome);
Assert.NotEqual(SessionStage.Failed, step.NewState.Stage);
var rearmed = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(rearmed);
Assert.Equal(fireAt.AddMinutes(30), rearmed!.DueAtUtc);
var schedule = step.Effects.Single(e => e.Kind == DecisionEffectKind.ScheduleDeadline);
Assert.Equal(DeadlineNames.AdvisoryCompletion, schedule.Deadline!.Name);
var waiting = SingleTimelineEffect(step, "completion_waiting");
Assert.Contains("CompletionGateHolding", waiting.Parameters!["trigger"]);
Assert.Contains("realmjoin_resolution", waiting.Parameters!["missingPrerequisites"]);
Assert.Equal(rearmed.DueAtUtc.ToString("o"), waiting.Parameters!["resolutionDeadlineDueAtUtc"]);
}
[Fact]
public void Session81daa77f_RealmJoinResolvesAfterTheWindow_CompletesInsteadOfFailing()
{
// End-to-end proof against the real field shape: before this guard the session was
// stamped Failed at exit+30 min with esp_exit_without_completion_evidence, 34 min
// before the RealmJoin gate would have released it.
var engine = new DecisionEngine();
var state = SetupEspExitDeadEnd(engine);
state = AddDesktopRealmJoinAndHello(engine, state);
var fired = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(33), DeadlineNames.AdvisoryCompletion));
Assert.Null(fired.NewState.Outcome);
// RealmJoin reaches phase 110 — the gate opens and the session completes.
var resolved = engine.Reduce(fired.NewState, MakeSignal(
80, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(50),
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" }));
Assert.Equal(SessionStage.Finalizing, resolved.NewState.Stage);
Assert.Equal("Resolved", resolved.NewState.RealmJoinFacts.Outcome!.Value);
var grace = FindDeadline(resolved.NewState, DeadlineNames.FinalizingGrace);
Assert.NotNull(grace);
var completed = engine.Reduce(resolved.NewState,
DeadlineFired(90, grace!.DueAtUtc, DeadlineNames.FinalizingGrace));
Assert.Equal(SessionStage.Completed, completed.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentComplete, completed.NewState.Outcome);
}
// ============================================================== convergence ====
[Fact]
public void DeadlineFired_AfterTheGateOpened_ResolvesNormallyAgain()
{
// Convergence proof: the guard is not an escape hatch. Once RealmJoin timed out the
// gate is open, the guard stops applying, and a session with no completion evidence
// fails exactly as before.
var engine = new DecisionEngine();
var state = SetupEspExitDeadEnd(engine);
state = engine.Reduce(state, MakeSignal(
50, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(6),
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0" })).NewState;
var rjDeadline = FindDeadline(state, DeadlineNames.RealmJoinTimeout);
Assert.NotNull(rjDeadline);
state = engine.Reduce(state,
DeadlineFired(60, rjDeadline!.DueAtUtc, DeadlineNames.RealmJoinTimeout)).NewState;
Assert.True(DecisionEngine.RealmJoinGateOpen(state));
Assert.Null(FindDeadline(state, DeadlineNames.RealmJoinTimeout));
// No desktop, no Hello, no IME evidence — the genuine dead-end shape.
var step = engine.Reduce(state,
DeadlineFired(70, T0.AddMinutes(80), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
var failed = SingleTimelineEffect(step, "enrollment_failed");
Assert.Equal("esp_exit_without_completion_evidence", failed.Parameters!["reason"]);
}
// =========================================================== mutation proofs ====
[Fact]
public void DeadlineFired_WithoutRealmJoin_StillFails()
{
// The guard must key on an actually-held gate, never on the absence of evidence.
var engine = new DecisionEngine();
var state = SetupEspExitDeadEnd(engine);
var step = engine.Reduce(state,
DeadlineFired(70, T0.AddMinutes(33), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
var failed = SingleTimelineEffect(step, "enrollment_failed");
Assert.Equal("esp_exit_without_completion_evidence", failed.Parameters!["reason"]);
}
[Fact]
public void DeadlineFired_GateClosedButItsDeadlineNoLongerArmed_StillFails()
{
// A gate that can no longer resolve itself must not park the session forever. The
// hand-built state drops only the RealmJoin deadline, leaving the gate closed.
var engine = new DecisionEngine();
var state = SetupEspExitDeadEnd(engine);
state = AddDesktopRealmJoinAndHello(engine, state);
var builder = state.ToBuilder();
builder.CancelDeadline(DeadlineNames.RealmJoinTimeout);
state = builder.Build();
Assert.False(DecisionEngine.RealmJoinGateOpen(state));
Assert.Null(FindDeadline(state, DeadlineNames.RealmJoinTimeout));
var step = engine.Reduce(state,
DeadlineFired(70, T0.AddMinutes(33), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
}
}
}
@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
@@ -172,7 +173,7 @@ public class BuiltInRulesTests
"policiesDiscovered", "ignoreCompletedApp", "imeAgentVersion",
"espTrackStatus", "updateName", "updateWin32AppState",
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation", "imeTokenFailure",
"enrollmentCompleted", "updateDoTelemetry", "scriptStarted",
"enrollmentCompleted", "userSessionZeroApps", "updateDoTelemetry", "scriptStarted",
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
"resetPlatformScriptContext",
"healthScriptResult", "healthScriptDetectionResult",
@@ -201,4 +202,38 @@ public class BuiltInRulesTests
$"Pattern {pattern.PatternId} has invalid category '{pattern.Category}'");
}
}
/// <summary>
/// Sessions 81daa77f / 75d6ae8e (2026-08-25). IME writes this line and then returns before
/// its "Completed user session" statement, so on a device with no user-targeted Win32 apps
/// it is the ONLY evidence that user-phase enforcement finished. The exact wording is
/// pinned here against the real IME line (verified against decompiled IME 1.104.102.0,
/// ApplicationPoller and AppWorkloadAbstraction) because a silent drift would bring back
/// the 30-min esp_exit_without_completion_evidence false positive with no test failing.
/// </summary>
[Fact]
public void BuiltInImeLogPatterns_ZeroUserAppsPattern_MatchesTheRealImeLine()
{
var pattern = Assert.Single(
BuiltInImeLogPatterns.GetAll(),
p => p.PatternId == "IME-USER-SESSION-ZERO-APPS");
Assert.Equal("userSessionZeroApps", pattern.Action);
Assert.Equal("always", pattern.Category);
var regex = new Regex(pattern.Pattern, RegexOptions.CultureInvariant);
var real = "[Win32App] Get 0 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020";
var match = regex.Match(real);
Assert.True(match.Success, "zero-app user session line must match");
Assert.Equal("1", match.Groups["sessionId"].Value);
Assert.Equal("4a1ba5db-44ed-4e69-8477-47ed88e76020", match.Groups["userId"].Value);
// Must not swallow a session that DOES have apps — that would fabricate completion
// evidence while enforcement is still running.
Assert.DoesNotMatch(regex,
"[Win32App] Get 3 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
Assert.DoesNotMatch(regex,
"[Win32App] Get 10 apps for user session 1, user id = 4a1ba5db-44ed-4e69-8477-47ed88e76020");
}
}
@@ -431,6 +431,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
internal static bool HasAdvisoryCompletionDeadline(DecisionState state)
=> FindAdvisoryCompletionDeadline(state) != null;
/// <summary>True when a deadline with the given name is currently armed.</summary>
internal static bool HasDeadline(DecisionState state, string name)
{
foreach (var d in state.Deadlines)
{
if (d.Name == name) return true;
}
return false;
}
/// <summary>The armed <see cref="DeadlineNames.AdvisoryCompletion"/> deadline, or null.</summary>
internal static ActiveDeadline? FindAdvisoryCompletionDeadline(DecisionState state)
{
@@ -912,6 +922,61 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(neverObservedState, neverObservedTransition, neverObservedEffects.ToArray());
}
// Completion-gate guard (sessions 81daa77f / 75d6ae8e, 2026-08-25) — a completion
// gate that is deliberately holding this session must not be overrun by this
// window. The RealmJoin gate blocks completion until phase 110, and it carries its
// own bounded resolution: 60 min from detection, re-armed on deployment activity,
// hard-capped at 4 h. This 30-min window is simply shorter, so on every device
// whose RealmJoin first deployment outlives it the session was failed while the
// engine's own `completion_waiting` still read "waiting on: realmjoin_resolution"
// — 75d6ae8e installed 25 RealmJoin packages successfully inside the window, the
// last one 2 min before the failure. Re-arm and let the gate's own deadline decide.
//
// Convergent: the gate resolves itself either way (Resolved / FirstDeploymentIncomplete
// / Timeout all set RealmJoinFacts.Outcome, which opens RealmJoinGateOpen), after
// which this guard stops applying and the next fire resolves the session normally.
// Requiring the gate's deadline to still be armed keeps a gate that can no longer
// resolve itself from parking the session forever; AgentMaxLifetimeMinutes bounds
// it regardless.
if (!RealmJoinGateOpen(state) && HasDeadline(state, DeadlineNames.RealmJoinTimeout))
{
var gateRearmedDeadline = BuildAdvisoryCompletionDeadline(state, signal);
builder.AddDeadline(gateRearmedDeadline);
var gateTrigger = $"DeadlineFired:{DeadlineNames.AdvisoryCompletion}:CompletionGateHolding";
var gateRearmedState = builder.Build();
var gateRearmedTransition = BuildTakenTransition(
before: state,
signal: signal,
toStage: state.Stage,
nextStepIndex: nextStep,
trigger: gateTrigger);
// Same deliberate dedupe bypass as the enforcement-progress re-arm: the missing
// set is unchanged since arming, but the new due-time is exactly what an
// operator debugging the session needs. Bounded to one per window.
var gateRearmedEffects = new[]
{
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: gateRearmedDeadline),
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: new Dictionary<string, string>
{
["eventType"] = SharedConstants.EventTypes.CompletionWaiting,
["source"] = "DecisionEngine",
["severity"] = "Info",
["immediateUpload"] = "false",
["message"] = "Completion resolution window re-armed: a completion gate is still holding the session",
["missingPrerequisites"] = string.Join(",", BuildMissingCompletionPrerequisites(state)),
["trigger"] = gateTrigger,
["stage"] = state.Stage.ToString(),
["resolutionDeadlineDueAtUtc"] = gateRearmedDeadline.DueAtUtc.ToString("o"),
}),
};
return new DecisionStep(gateRearmedState, gateRearmedTransition, gateRearmedEffects);
}
// Conjunction not met — the session is failed. The two arming variants carry
// distinct context (see method doc): the advisory variant un-defangs the original
// ESP failure; the esp-exit variant never had an ESP failure, so it gets its own