Skip to content

Windows FMA patch policies don't distinguish per-user vs per-machine install scope, leaving duplicate/stale copies #48248

Description

@allenhouchins

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:

  • Have been confirmed to consistently lead to reproduction in multiple Fleet instances.
  • Describe the workflow that led to the error, but have not yet been reproduced in multiple Fleet instances.
  1. 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.
  2. Enable that FMA's automatic-install / patch policy for the host's team.
  3. 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.goapp.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

  • Document the guardrail (never scope-narrow the detection query) in the FMA authoring guide.
  • Set installer_scope on the 9 unset-scope inputs.
  • Resolve the MSIX scope mismatch (6): decide user vs machine, align script + detection, confirm programs visibility on a Windows host.
  • Implement Pattern A/B remediation for the 18 machine dual-variant apps (start with PowerToys + VS Code as references).
  • Decide handling for the 44 user-scope apps (per-user install as SYSTEM correctness + lingering machine copy).
  • Add validator enforcement: installer_scope is set and consistent with install/uninstall scripts and detection.
  • Reproduce on a live Windows host and add manual verification.

Metadata

Metadata

Assignees

Labels

:help-solutions-consultingTasks for Solutions ConsultantsP2Urgent: Supported workflow not functioning as intended, newly drafted feature with urgent Fleet needbugSomething isn't working as documented

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
New requests

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions