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>
7.5 KiB
type, title, description, resource, tags, timestamp
| type | title | description | resource | tags | timestamp | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| Concept | Zero User-Targeted Apps — the starved user-phase completion gate | Why a device with no user-targeted Intune apps could never satisfy the IME user-session gate, how that turned into a 30-minute esp_exit_without_completion_evidence false positive on a third of one tenant's enrollments, and the two fixes — reading the line IME actually writes in its zero-app branch, and refusing to fail a session while a completion gate is deliberately holding it. | /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime |
|
2026-08-25 |
Summary
A Classic user-driven enrollment whose user has zero targeted Intune Win32 apps could not
produce the evidence every user-phase completion path required. The session parked, and the
30-minute advisory_completion backstop resolved it as
enrollment_failed: esp_exit_without_completion_evidence — while the user was at a working
desktop, Windows Hello was provisioned, and RealmJoin was still installing packages.
One tenant hit this on 10 of 30 enrollments in a week. Every one of them was a false positive; the devices were healthy in Intune and RealmJoin.
The starvation chain
The IME writes Completed user session N, userId: … at the end of a user-session processing
pass. IME-USER-SESSION-COMPLETED matches it and the adapter posts
ImeUserSessionCompleted, which sets DecisionState.ImeUserSessionCompletedUtc.
But the IME never reaches that statement when the user has no assigned apps. Decompiled IME
1.104.102.0, Win32AppPlugIn/ApplicationPoller.cs:
if (!appPolicies.Any())
{
AppWorkloadLog.TraceInformation($"[Win32App] Get 0 apps for user session {user.RDSSessionId}, user id = {user.IntuneId}");
RegisterApplicationsInIfInESP(sender, user, delegate {
espLockInProcessor.AttemptToLockInApplications(user, new List<ProcessorSubgraph>());
});
continue; // skips the "Completed user session" statement below
}
Identical shape in 1.97.107.0, 1.83.107.0, 1.80.132.0. With that fact missing, every door
out of the user phase is shut at once:
| Path | Requirement | Why it is dead |
|---|---|---|
ShouldTransitionToAwaitingHello arm A |
AccountSetupProvisioningSucceededUtc |
ESP registry froze at AccountSetup: 1 of 5; neither categorySucceeded nor the all-subcategories fallback can ever hold |
| arm B | SkipUserEsp |
Full ESP, not a skip-user flow |
| arm C | IsImeUserSessionGenuine |
needs the missing IME fact |
| arm D | EnrollmentMode.DevicePreparation |
Autopilot v1, not WDP |
MaybeSynthesizeAccountSetupCompleteFromSettledUserApps |
AreUserEspAppsSettled |
returned false on an empty app list |
HandleAdvisoryCompletionDeadlineFired conjunction |
imeUserSessionGenuine || isObservationAdvisory |
both false |
The last row is the one that produced the verdict: the backstop that exists to resolve a dead-end resolved it as a failure.
AreUserEspAppsSettled deserves its own note. Its "at least one required app" rule is
deliberately conservative — an empty list normally means the phase just cleared or apps have
not surfaced yet. But it made zero apps, the most settled state possible, indistinguishable
from unknown, and there was no evidence available to tell them apart.
Fix 1 — read the line IME does write
IME-USER-SESSION-ZERO-APPS matches the zero-app line and routes it through the same
OnUserSessionCompleted callback. Downstream this genuinely is a completed user session, so
the adapter's pending-apps deferral and its fire-once flag apply unchanged.
This is not a weakened gate — it is IME's own verdict. The zero-app branch locks the ESP in with
an empty application list, and the sibling emit site in AppWorkloadAbstraction.ExecuteUserCheckIn
returns ProviderResult.ProvisioningComplete straight after logging it. All three emit sites are
user-session-scoped (user.RDSSessionId, user.IntuneId); none is device-scoped.
Guards that stay in force:
- Phase guard — the observation only fires while the tracker is in
AccountSetup. Firing earlier would burn the adapter's fire-once flag on a pre-sign-in (defaultuser0) timestamp thatIsImeUserSessionGenuine(>= AccountSetupEnteredUtc) can never accept. An observation made before the phase was known is remembered and replayed on the transition into it, because IME writes its check-in lines independently of when the agent first parses the phase marker. - Engine guards unchanged — arm C still requires a post-AccountSetup final ESP exit and a DAD-validated real-user desktop.
AreUserEspAppsSettledreturnstruefor an empty list only with this explicit observation — never for a merely empty list.
Fix 2 — do not fail while a completion gate is holding
An independent defect, and the decisive one for the affected tenant. advisory_completion runs
for 30 minutes. The RealmJoin completion gate blocks completion until phase 110 and carries its
own bounded resolution: 60 minutes from detection, re-armed on deployment activity, hard-capped
at 4 hours. The shorter timer always won.
Session 75d6ae8e installed 25 RealmJoin packages successfully inside the window, the last
one 2 minutes before the failure, while the engine's own completion_waiting still read
waiting on: realmjoin_resolution.
HandleAdvisoryCompletionDeadlineFired now re-arms instead of failing when the RealmJoin gate is
closed and its deadline is still armed, emitting completion_waiting with trigger
DeadlineFired:advisory_completion:CompletionGateHolding.
Convergence comes from the gate itself: Resolved, FirstDeploymentIncomplete and Timeout all
set RealmJoinFacts.Outcome, which opens RealmJoinGateOpen and stops the guard applying.
Requiring the gate's deadline to still be armed keeps a gate that can no longer resolve itself
from parking the session; AgentMaxLifetimeMinutes bounds it regardless.
Timeline evidence
ime_user_session_completed carries an evidence field so the basis of the verdict is legible
without the diagnostics archive:
user_session_completed— IME wrote its completion line and no required app was pending.zero_user_apps_enumerated— IME reported zero targeted apps; the user phase had nothing to enforce.
No new event type: the fact is the same, only the observation behind it differs.
Deployment
The pattern ships through the config API and reaches existing agents without a rebuild; an agent
that does not know the userSessionZeroApps action logs one Debug line and ignores it. The
behaviour needs an agent release. Sessions already stamped Failed are not reclassified — the
backend mirrors the agent verdict (VerdictPaths.AgentFailed) and has no independent judgement
here by design.
Citations
rules/ime-log-patterns/IME-USER-SESSION-ZERO-APPS.jsonsrc/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs—HandleUserSessionZeroApps, the phase replay inHandleEspPhaseDetectedsrc/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs—AreUserEspAppsSettledsrc/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ImeLogTrackerAdapter.cs—EmitUserSessionCompleted, evidence labelssrc/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs—HandleAdvisoryCompletionDeadlineFired- decision-engine.md, post-reboot-completion-recovery.md