Fleet versions
- Discovered: All versions with Fleet-maintained app (FMA) patch policies — found via code/manifest analysis on
main, 2026-06-24.
- Reproduced: Not yet reproduced on a live instance (analysis-based; reproduction steps below).
Web browser and operating system: Windows hosts (per-user vs per-machine app installs); server-side patch-policy logic.
💥 Actual behavior
On Windows, many apps can be installed per-user (HKCU, %LOCALAPPDATA%) or per-machine (HKLM, Program Files), and some ship both. An FMA's patch policy is its exists detection query plus a version check (pkg/patch_policy/patch_policy.go), and that query matches both scopes (programs reads HKLM + per-user hives) by name/publisher only.
When a host has an app at one scope and the FMA installs at the other scope, the result is:
- A second copy is installed; the original stale copy is left behind, unmanaged.
- The patch policy keeps matching the stale copy, so the host never reports as patched and installs may keep retrying.
Reported in product feedback for Slack, PowerToys, GIMP (and applies to VS Code, Chrome, and others).
🛠️ Expected behavior
After a patch policy runs, the device has a single, up-to-date copy of the app and the policy reports compliant — with no unmanaged, unpatched copy left at the other scope.
Hard requirement: the policy must never report "patched" while a stale copy of the app remains at any scope. (See "the trap" below — the obvious fix violates this.)
🧑💻 Steps to reproduce
These steps:
- On a Windows host, install an app per-user that an FMA targets per-machine — e.g. PowerToys via
PowerToysUserSetup — at a version older than the FMA target.
- Enable that FMA's automatic-install / patch policy for the host's team.
- Fleet installs the per-machine copy. Observe: both the per-user (old) and per-machine (new) copies are present, and the patch policy continues to report the host as failing / not patched.
🕯️ More info
Root cause
The patch policy is not a separate query — it's the exists query with a version comparison appended, wrapped in NOT EXISTS:
patched = SELECT 1 WHERE NOT EXISTS ( <exists-query-body> AND version_compare(version,'X') < 0 )
(pkg/patch_policy/patch_policy.go; the manifest patched wraps exists in ee/maintained-apps/ingesters/winget/ingester.go; it becomes the policy via server/mdm/maintainedapps/sync.go → app.PatchQuery.)
Detection scope and "is it patched?" scope are the same predicate — you can't change one without the other. All 302 Windows FMAs use a scope-blind ("all-hives") detection query, so the policy sees both scopes; combined with single-scope installers, the stale opposite-scope copy keeps the policy red and copies pile up.
⚠️ The trap: do NOT fix this in the detection query
Narrowing the query to "system only" (a tempting fix) makes the policy go false-green: it stops seeing the stale per-user copy and reports patched while an unmanaged, unpatched copy remains on the device. That violates the hard requirement above. The fix must live in remediation, not the query.
Proposed fix
Keep the detection query scope-blind, and fix remediation so no stale copy survives:
- Pattern A — remove-and-replace (default): the install script removes any other-scope copy, then installs one canonical copy at target. The all-hives policy then truthfully goes green. (PowerToys' uninstall script already enumerates
HKLM+HKCU.)
- Pattern B — update-in-place per scope: detect which scope the existing copy lives in and upgrade that one (per-user installs run in the user's session — Slack's MSIX script already does this via a scheduled task). More data-safe; per-user-from-SYSTEM is the hard case.
Guardrails: (1) never scope-narrow the exists predicate; (2) harden over-narrow name = '…' matches that could miss a variant; (3) validate the MSIX class on a Windows host (confirm the MSIX appears in programs at all); (4) make installer ↔ detection ↔ uninstaller agree per app.
Data-preservation note
A same-scope upgrade (the common case) preserves data normally. The risk is the cross-scope case (Pattern A removing the opposite-scope copy): PRESERVED (config in %APPDATA%/HKCU, same family — e.g. VS Code), PARTIAL (Squirrel/Electron local session/cache lost), or RESET (cross-packaging to MSIX — e.g. Slack: data won't carry). 0 of 302 uninstall scripts purge user data today, which favors preservation — but the MSIX class can't preserve data regardless of pattern. "No false-green" and "no surprise data loss" are two separate guarantees.
Impact across all 302 apps (verified from installer_scope in input files)
| Group |
Verified target |
Count |
Risk |
MSIX (declared user, provisioned machine-wide — mismatch) |
user/machine |
6 |
HIGH |
| User-scope, non-MSIX (installed per-user as SYSTEM) |
user |
44 |
MED–HIGH |
| Machine, dual-variant (per-user copy can linger) |
machine |
18 |
HIGH |
| Machine, single-variant (machine-only LOB/runtimes) |
machine |
225 |
LOW |
Unset scope (installer_scope missing in input) |
— |
9 |
FIX |
MSIX (6): affinity, arc, claude, microsoft-teams, slack, windows-app
Unset scope — quick input fix (9): descript, electrum, microsoft-dotnet-runtime-10, microsoft-dotnet-runtime-8, notesnook, ocenaudio, protopie, requestly, standard-notes
Machine + dual-variant — high priority (18): 1password, box-drive, cursor, dropbox, firefox, firefox@esr, gimp, github-desktop, google-chrome, google-drive, microsoft-edge, onedrive, powertoys, visual-studio-code, vivaldi, vscodium, windsurf, zoom
User-scope + dual-variant (4): antigravity-ide, brave-browser, kiro, mullvad-browser
User-scope, per-user install as SYSTEM — remaining 40
amazon-chime, asana, balenaetcher, bdash, biscuit, bluej, canva, dataflare, devtoys, dialpad, discord, dynalist, fellow, figma, fork, franz, gitkraken, granola, groove-omnidialer, insomnia, jetbrains-toolbox, julia-app, loom, miro, morgen, nordpass, notion, notion-calendar, ollama, postman, proton-drive, proxyman, readest, signal, spotify, sqlectron, telegram, termius, todoist-app, yaak
The dual-variant overlay (which apps have a common installer at the opposite scope) is curated; verified target scope is authoritative for all 302. Confirming "is the opposite scope common for this app?" is the host-validation step. The full 302-app classification (verified target scope per app) is available on request.
Proposed work / acceptance criteria
Fleet versions
main, 2026-06-24.Web browser and operating system: Windows hosts (per-user vs per-machine app installs); server-side patch-policy logic.
💥 Actual behavior
On Windows, many apps can be installed per-user (
HKCU,%LOCALAPPDATA%) or per-machine (HKLM,Program Files), and some ship both. An FMA's patch policy is itsexistsdetection query plus a version check (pkg/patch_policy/patch_policy.go), and that query matches both scopes (programsreadsHKLM+ per-user hives) byname/publisheronly.When a host has an app at one scope and the FMA installs at the other scope, the result is:
Reported in product feedback for Slack, PowerToys, GIMP (and applies to VS Code, Chrome, and others).
🛠️ Expected behavior
After a patch policy runs, the device has a single, up-to-date copy of the app and the policy reports compliant — with no unmanaged, unpatched copy left at the other scope.
🧑💻 Steps to reproduce
These steps:
PowerToysUserSetup— at a version older than the FMA target.🕯️ More info
Root cause
The patch policy is not a separate query — it's the
existsquery with a version comparison appended, wrapped inNOT EXISTS:(
pkg/patch_policy/patch_policy.go; the manifestpatchedwrapsexistsinee/maintained-apps/ingesters/winget/ingester.go; it becomes the policy viaserver/mdm/maintainedapps/sync.go→app.PatchQuery.)Detection scope and "is it patched?" scope are the same predicate — you can't change one without the other. All 302 Windows FMAs use a scope-blind ("all-hives") detection query, so the policy sees both scopes; combined with single-scope installers, the stale opposite-scope copy keeps the policy red and copies pile up.
Narrowing the query to "system only" (a tempting fix) makes the policy go false-green: it stops seeing the stale per-user copy and reports patched while an unmanaged, unpatched copy remains on the device. That violates the hard requirement above. The fix must live in remediation, not the query.
Proposed fix
Keep the detection query scope-blind, and fix remediation so no stale copy survives:
HKLM+HKCU.)Guardrails: (1) never scope-narrow the
existspredicate; (2) harden over-narrowname = '…'matches that could miss a variant; (3) validate the MSIX class on a Windows host (confirm the MSIX appears inprogramsat all); (4) make installer ↔ detection ↔ uninstaller agree per app.Data-preservation note
A same-scope upgrade (the common case) preserves data normally. The risk is the cross-scope case (Pattern A removing the opposite-scope copy): PRESERVED (config in
%APPDATA%/HKCU, same family — e.g. VS Code), PARTIAL (Squirrel/Electron local session/cache lost), or RESET (cross-packaging to MSIX — e.g. Slack: data won't carry). 0 of 302 uninstall scripts purge user data today, which favors preservation — but the MSIX class can't preserve data regardless of pattern. "No false-green" and "no surprise data loss" are two separate guarantees.Impact across all 302 apps (verified from
installer_scopein input files)user, provisioned machine-wide — mismatch)installer_scopemissing in input)MSIX (6):
affinity,arc,claude,microsoft-teams,slack,windows-appUnset scope — quick input fix (9):
descript,electrum,microsoft-dotnet-runtime-10,microsoft-dotnet-runtime-8,notesnook,ocenaudio,protopie,requestly,standard-notesMachine + dual-variant — high priority (18):
1password,box-drive,cursor,dropbox,firefox,firefox@esr,gimp,github-desktop,google-chrome,google-drive,microsoft-edge,onedrive,powertoys,visual-studio-code,vivaldi,vscodium,windsurf,zoomUser-scope + dual-variant (4):
antigravity-ide,brave-browser,kiro,mullvad-browserUser-scope, per-user install as SYSTEM — remaining 40
amazon-chime,asana,balenaetcher,bdash,biscuit,bluej,canva,dataflare,devtoys,dialpad,discord,dynalist,fellow,figma,fork,franz,gitkraken,granola,groove-omnidialer,insomnia,jetbrains-toolbox,julia-app,loom,miro,morgen,nordpass,notion,notion-calendar,ollama,postman,proton-drive,proxyman,readest,signal,spotify,sqlectron,telegram,termius,todoist-app,yaakProposed work / acceptance criteria
installer_scopeon the 9 unset-scope inputs.programsvisibility on a Windows host.installer_scopeis set and consistent with install/uninstall scripts and detection.