HomeNewsletterCommunityMVP FeedToolsArchiveBlogToday's NewsAboutServicesQuick Links Subscribe free
← Back to Blog
Windows Update Windows UpdatePowerShellTroubleshootingRegistryIntunePolicyManagerCBSWindows 11

One read-only PowerShell collector for Windows patching failures - and the six traps that make naive versions lie to you

IA
Imran Awan
23 August 2026

This is the last post in a forty-part series on Windows patching troubleshooting, and it exists because of something six of the earlier posts each discovered independently, without any of them setting out to.

Every single one of them was writing a small diagnostic snippet. Every single one of them found that the snippet lied.

Not crashed. Not errored. Lied — returned a clean, confident, plausible verdict that was flatly contradicted by the device it had just read. Six times, in six different subsystems, with six different registry keys. That is not six coincidences. That is a pattern, and it has a shape: on Windows, checking whether something exists is not the same as checking what it says, and the gap between those two questions is where triage scripts go to die.

Post 40 in this series covers the human side of that — which log to read for which symptom, and the five reflex fixes that destroy your evidence before you get to it. It names the pattern "the empty-key trap". This post owns the automation of it: why a patching triage script is far harder to write correctly than it looks, what each trap does to a naive implementation, and a single read-only collector that handles all of them.

Everything measured below came off one corporate Windows 11 Enterprise device — 25H2, build 26200.9168, servicing stack 10.0.26100.9156 — read only, on 23 August 2026. The script was written, saved, and then run from disk four times with different switches before a single number went into this post.

The short version

A patching triage script fails in a specific, repeatable way: it answers "does this key exist" when the question was "what does this device do", and the two disagree constantly. Measured on one device in one session: the CBS RebootPending marker is a subkey, so a value-based check reports "no reboot pending" permanently — and Microsoft's own published pending-reboot script gets this right by calling GetSubKeyNames(). ...\WindowsUpdate\AU exists with ValueCount 0, so a Test-Path reports legacy Automatic Updates management on a device that has none. ...\CurrentVersion\Policies\Servicing exists holding only CountryCode. ...\SystemCertificates\AuthRoot exists with zero values and three auto-created subkeys. IsWUfBConfigured read 0 at the same instant as IsDeferralIsActive=1 and a seven-day quality deferral. The MDM WMI bridge returned 2 / 0 / 7 / 2 to an elevated administrator while the registry held 6 / 7 / 30 / 7 — every value its documented default, and no error raised. The classic Group Policy hive held 2 values while the MDM hive held 23; the classic Delivery Optimization key did not exist at all while 17 DO policies were in force. The legacy Auto Update\Results\{Detect,Download,Install} keys are gone on this build, so the classic LastSuccessTime scan check silently returns nothing. And [Environment]::OSVersion.Version reports revision .0 while Win32_OperatingSystem.Version reports no revision at all, so neither can tell you the patch level. The answer is a collector that reports key state rather than key presence, never uses IsWUfBConfigured as a test, reads both policy hives side by side, takes the UBR from the registry paired with CurrentBuild, and changes absolutely nothing. It is on GitHub, it produces a self-contained shareable HTML report, and it audits its own source to prove the read-only claim.

The problem: your triage script returns a confident answer that is wrong

Here is the scenario, and if you manage a fleet you have lived it. A batch of devices is not patching. You need a first pass across all of them, so you write forty lines of PowerShell: build, pending reboot, services, policy, last scan. You run it against the estate, get a tidy table back, and start working from it.

The table is wrong in at least three columns, and nothing in the output tells you which three.

This is worse than a script that crashes. A crash sends you to the device. A confident false negative sends you to the wrong theory, and you spend the afternoon there. On the lab device, a naive collector and a correct one were run against the same registry within the same minute and disagreed on the pending-reboot verdict, the management state, and the last-scan time.

The uncomfortable part is that the naive version is the version everybody writes, because it is the version that reads correctly in English. "Is a reboot pending? Test the RebootPending value." "Is this device managed by Windows Update for Business? Read IsWUfBConfigured." "Is legacy Automatic Updates policy in force? Test-Path the AU key." Each of those sentences is a perfectly sensible thing to say, and each of them maps to a line of PowerShell that returns the wrong answer on a real 25H2 device.

PowerShell 5.1 (admin) — the naive triage script, real output, 26200.9168
PS C:\> # The four checks almost every homegrown triage script contains. PS C:\> $cbs = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing' PS C:\> (Get-ItemProperty $cbs).RebootPending PS C:\> # ...nothing. No output, no error. Reads as "no reboot pending". PS C:\> Test-Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' True PS C:\> # Reads as "legacy Automatic Updates policy is configured here". PS C:\> (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UpdatePolicy\PolicyState').IsWUfBConfigured 0 PS C:\> # Reads as "this device is not WUfB-managed". PS C:\> $r = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\Results\Detect' PS C:\> (Get-ItemProperty $r -ErrorAction SilentlyContinue).LastSuccessTime PS C:\> # ...nothing. Most dashboards render this as "never scanned". # Four checks. Four confident verdicts. On this device, all four are wrong: # RebootPending is a SUBKEY, so that value can never exist. # The AU key exists with ValueCount 0 - zero legacy policy values. # IsWUfBConfigured=0 sits beside IsDeferralIsActive=1 on the same read. # The Results\Detect key does not exist on this build at all.

Read that block again and notice what is missing: an error. Not one of those four lines failed. Two returned nothing, one returned True, one returned 0. In PowerShell, "returned nothing" and "returned false" and "returned zero" all collapse into the same falsy value the moment you put them in an if, and that is where the wrong verdict gets born.

Watch out: the reflex at this point is to stop diagnosing and start fixing — reset SoftwareDistribution, restart the services, run SFC, retry. Every one of those actions destroys evidence, and none of them addresses a script that was reading the wrong key. If your triage tooling is producing false negatives, running a remediation against its output does not fix the device; it burns the CBS logs, the BITS queue and the Datastore that would have told you what was actually wrong. Fix the reading before you touch the device.

Why it happens: six places where existence is not evidence

The root cause is architectural, and once you see it you cannot unsee it. A Windows policy hive is not a database of settings. It is a namespace, and lots of different components create keys in it for lots of different reasons that have nothing to do with a policy being configured.

Group Policy creates keys when it applies a setting and, crucially, sometimes leaves them behind when it stops. The MDM policy stack creates a parallel set of keys somewhere else entirely. Certificate auto-update creates its own subkeys the first time it runs regardless of policy. The servicing stack uses subkeys as flags rather than values because a subkey can be created and deleted atomically in a transaction. And a value can exist while holding an empty string, which is a third state that neither Test-Path nor a truthiness test can see.

So "the key is there" tells you almost nothing. Here are the six instances confirmed on the lab device, each of which breaks a different naive check.

TrapWhat the naive check doesWhat is actually true (measured)
1. CBS reboot markers are subkeysGet-ItemProperty tests for a value named RebootPending. Returns nothing, forever.RebootPending, RebootInProgress and PackagesPending are subkeys of the Component Based Servicing key. Microsoft's own published CheckForPendingReboot.ps1 opens the CBS key and enumerates GetSubKeyNames(). Measured: 14 subkeys present, then 15 a minute later.
2. The AU key exists emptyTest-Path ...\WindowsUpdate\AU returns True, read as "legacy Automatic Updates policy in force".Test-Path True, ValueCount 0, SubKeyCount 0, no properties. Not one of UseWUServer, NoAutoUpdate, AUOptions or ScheduledInstallDay is present. Verified three separate times on this device.
3. A policy key holding something unrelatedTest-Path ...\CurrentVersion\Policies\Servicing returns True, read as "an optional-component repair source is configured".The key exists with ValueCount 1, and the one value is CountryCode = GB. There is no LocalSourcePath. The key's existence is unrelated to the policy you were testing for.
4. Auto-created subkeysTest-Path ...\Policies\Microsoft\SystemCertificates\AuthRoot returns True, read as "root certificate auto-update has been policy-configured".ValueCount 0, SubKeyCount 3Certificates, CRLs and CTLs, created regardless of policy. DisableRootAutoUpdate is absent, which is the value that would actually have meant something.
5. A resolved flag that contradicts its own neighboursBranch on IsWUfBConfigured to decide whether the device is managed.On one read of one key: IsWUfBConfigured=0, IsDeferralIsActive=1, QualityUpdatesDeferralInDays=7, DeferQualityUpdates=1. The flag disagrees with the three values sitting beside it.
6. An API that returns defaults instead of failingGet-CimInstance MDM_Policy_Result01_Update02 from an elevated prompt, treated as the effective policy.Returned AllowAutoUpdate=2, DeferQualityUpdatesPeriodInDays=0, ConfigureDeadlineForQualityUpdates=7, ConfigureDeadlineGracePeriod=2 — every one its documented default — while the registry held 6, 7, 30, 7. No exception, no warning.

Trap 6 deserves its own paragraph because it is the nastiest of the six. Microsoft is explicit about the requirement, in the WMI Bridge Provider scripting article: "For all device settings, the WMI Bridge client must be executed under local system user." The documented way to satisfy that is psexec.exe -i -s cmd.exe. What is not documented is what happens when you ignore it. Measured behaviour: the query succeeds and returns the documented default for every property. You get plausible fiction with a clean exit code, which is the worst possible failure mode for a diagnostic.

Context: there is a good reason two policy hives exist and a bad reason people only audit one. The classic SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate hive is where Group Policy writes. The MDM stack writes to SOFTWARE\Microsoft\PolicyManager\current\device\Update instead, and the names differ — the Policy CSP node AllowAutoUpdate maps to the Group Policy value name AUOptions, so grepping the classic hive for the CSP name could never find it. Microsoft's Policy CSP reference gives a "Group policy mapping" registry key name for each setting, and that path is where the GPO twin lands, not where MDM writes the value. Read it as a translation table, not as a location.

That distinction is not academic. On the lab device the two hives are wildly asymmetric, and an audit that reads only the classic one concludes the device is barely managed at all.

HKLM\SOFTWARE\Microsoft\PolicyManager\current\device\Update

The MDM hive. Every value below was read on 23 August 2026. Note that the raw value count is 65 while the real policy count is 23 — PolicyManager decorates each policy with _ProviderSet, _WinningProvider and _LastWrite siblings, so counting raw names roughly triples the apparent configuration.

ValueMeaningWhat to look for
AllowAutoUpdateAutomatic update behaviour. The Policy CSP documents values 0 to 5 and maps this to the Group Policy value name AUOptions.Measured 6 — outside the published enumeration. Record it, do not assert a meaning for it. Its presence at all is what proves MDM is driving this setting.
DeferQualityUpdatesPeriodInDaysQuality-update deferral. Documented range 0-30.Measured 7. Compare against the classic hive, which does not contain this value at all on this device.
ConfigureDeadlineForQualityUpdatesDays before a pending quality update is forced. Documented range 0-30, default 7.Measured 30. Add it to the deferral, not instead of it.
ConfigureDeadlineGracePeriodGrace period after the deadline before the forced restart. Documented range 0-7, default 2.Measured 7. Deferral plus deadline plus grace is the number to quote: 7 + 30 + 7 = a 44-day worst case against a portal that reports 7.
TargetReleaseVersionThe release the device is pinned to, via MDM.Measured present but empty string. This is the third state: neither absent nor set. A Test-Path says the policy is there; a truthiness test says it is not. Both are wrong.
PauseQualityUpdatesStartTimeStart timestamp of an active quality-update pause.Measured present-but-empty with a _LastWrite sibling — the fingerprint of a pause that was set and then cleared, rather than one that never existed.
ConfigureDeadlineNoAutoRebootSuppresses the automatic restart at the deadline.Measured 1. Combined with a 16-hour active-hours window, this is why "the deadline passed and nothing rebooted" is not a bug.
QualityUpdateEnrolled / FeatureUpdateEnrolled / DriverUpdateEnrolledAutopatch-style enrolment nodes, written by a second management authority.Measured 0 / 1 / 1. Two distinct _WinningProvider GUIDs were present on this one device — check which authority owns which setting before you go looking in one portal.

And the agent's own resolved state key, which is the closest thing Windows has to "what did the update client actually conclude". Eighteen values, one of which cannot be trusted.

HKLM\SOFTWARE\Microsoft\WindowsUpdate\UpdatePolicy\PolicyState

This is the update agent's resolved verdict after it has merged every policy source. It is the most useful key in the entire diagnostic — and it contains the single most misleading value.

ValueMeaningWhat to look for
IsWUfBConfiguredThe agent's own claim about whether Windows Update for Business is configured.Measured 0 on a device with an active 7-day deferral. Never branch on this value. Report it for the record and derive the verdict from the policy values instead.
IsDeferralIsActiveWhether a deferral is currently in force. (The doubled "Is" is Microsoft's spelling, not a typo here.)Measured 1 at the same instant as IsWUfBConfigured=0. This is the value that is telling the truth.
QualityUpdatesDeferralInDaysThe resolved quality deferral the agent is applying.Measured 7, matching the MDM hive. Agreement between the resolved state and the policy hive is good evidence; disagreement is a finding.
BranchReadinessLevelThe servicing branch the agent resolved to.Measured as the string CB, while the Policy CSP documents this as an integer enumeration (2, 4, 8, 16, 32, 64, 128). Do not compare the two forms directly.
TargetReleaseVersion / TargetProductVersionThe resolved release pin.Measured 24H2 and Windows 11 on a device whose installed DisplayVersion is 25H2. The pin has been overtaken — it is no longer describing this device, so it is not what is holding it back.
PolicySourcesA bitfield describing which sources contributed to the resolved policy.Measured 4. Undocumented publicly; useful as a change detector across a fleet rather than as an absolute value.

One more class of trap, because it is the one that quietly corrupts compliance reporting rather than triage: the version APIs.

Gotcha: [Environment]::OSVersion.Version returned 10.0.26200.0 on this device and Win32_OperatingSystem.Version returned 10.0.26200 — a zero revision and no revision at all. The real patch level, 26200.9168, exists only in HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion as CurrentBuild plus UBR. And you must read both: KB5121003 produces UBR 9168 on 25H2 build 26200 and on 24H2 build 26100, so a compliance rule that tests UBR -ge 9168 without also pinning CurrentBuild passes a 24H2 device against a 25H2 baseline. While you are in that key, ProductName reads "Windows 10 Enterprise" on this Windows 11 device, ReleaseId is frozen at 2009, and BuildLabEx still carries a 26100 lab string — forensic proof the device was installed as 24H2 and moved up in place.

How to verify: reading each trap correctly, with the real output

Before writing the collector, each trap needs a read that is provably correct. That means, for every check, asking the same three questions: does the key exist, how many values and subkeys does it hold, and is the specific value I care about present, empty or absent. Three states, never one boolean.

Here is the pending-reboot check done properly, alongside the naive one, on the same key in the same second.

PowerShell 5.1 (admin) — CBS reboot markers, naive vs subkey-aware, real output
PS C:\> $rel = 'SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing' PS C:\> # NAIVE: test for a value. This is what most scripts do. PS C:\> (Get-ItemProperty ('HKLM:\' + $rel)).RebootPending -eq $null True PS C:\> Test-Path ('HKLM:\' + $rel + '\RebootPending') False PS C:\> # CORRECT: enumerate SUBKEY names, the way Microsoft's own script does. PS C:\> $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($rel) PS C:\> $subs = $key.GetSubKeyNames(); $key.Close() PS C:\> $subs.Count 15 PS C:\> $subs -join ', ' ApplicabilityEvaluationCache, CapabilityIndex, ComponentDetect, DriverOperations, Features on Demand, Interface, Notifications, PackageDetect, PackageIndex, Packages, RemovedEditionBasedSelectableUpdates, SessionsPending, TiRunning, UpdateDetect, Version PS C:\> foreach ($m in 'RebootPending','RebootInProgress','PackagesPending') { >> '{0,-18} {1}' -f $m, ($subs -contains $m) } RebootPending False RebootInProgress False PackagesPending False # Same verdict here - this device genuinely has no pending reboot. The point is # that the naive check would have said "False" on a device that DID. # Two other things in that subkey list matter: # SessionsPending exists on a healthy device. It is NOT a reboot marker. # TiRunning appeared between two runs a minute apart - the list went 14 -> 15 # because TrustedInstaller started. So "unexpected subkey means pending reboot" # is also wrong. Test the NAMED markers, nothing else.

That TiRunning observation is worth sitting with for a second. The set of CBS subkeys is not a constant. It changed between two consecutive runs of the same read-only script, because the Windows Modules Installer service transitioned from Stopped to Running in between. Any heuristic of the form "this key has more subkeys than I expected, something must be pending" is measuring service state, not servicing state.

Next, the two policy hives, side by side. This is the check that changes how you read every Intune-managed device.

PowerShell 5.1 (admin) — both policy hives, real output from the collector
Classic Group Policy hive: HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate exists / ValueCount / SubKeys : True / 2 / 1 values : TargetReleaseVersion, TargetReleaseVersionInfo TargetReleaseVersion = 1 TargetReleaseVersionInfo = 24H2 HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU <- the legacy AU subkey Test-Path says : True ValueCount / SubKeyCount : 0 / 0 UseWUServer = <absent> NoAutoUpdate = <absent> AUOptions = <absent> ScheduledInstallDay = <absent> -> The AU key EXISTS and holds ZERO legacy values. MDM policy hive: HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device\Update exists : True raw value names : 65 real policies (metadata excluded) : 23 AllowAutoUpdate = 6 DeferQualityUpdatesPeriodInDays = 7 ConfigureDeadlineForQualityUpdates = 30 ConfigureDeadlineGracePeriod = 7 ConfigureDeadlineNoAutoReboot = 1 SetDisablePauseUXAccess = 1 PauseQualityUpdatesStartTime = <present but EMPTY> TargetReleaseVersion = <present but EMPTY> Classic TargetReleaseVersionInfo : 24H2 Installed DisplayVersion : 25H2 -> Pinned to 24H2, running 25H2. The pin has been overtaken. Classic DO policy key exists : False MDM DO policies in force : 17 MDMWinsOverGP : 1 # Real output from one run; the MDM value list is trimmed here to the eight that # matter for this discussion - the run itself prints all 23. # Two values in the classic hive. Twenty-three in the MDM hive. And the classic # Delivery Optimization key does not exist AT ALL while 17 DO policies are live. # A GPO-shaped audit of DO on this device finds literally nothing.

Then the bridge. This one is worth running deliberately, once, so you have seen it with your own eyes and never trust it again from the wrong context.

PowerShell 5.1 (elevated admin, NOT SYSTEM) — MDM bridge vs registry, real output
NOT running as SYSTEM. Microsoft: "For all device settings, the WMI Bridge client must be executed under local system user." Treat every number below as untrusted. AllowAutoUpdate bridge=2 registry=6 <-- DISAGREE DeferQualityUpdatesPeriodInDays bridge=0 registry=7 <-- DISAGREE ConfigureDeadlineForQualityUpdates bridge=7 registry=30 <-- DISAGREE ConfigureDeadlineGracePeriod bridge=2 registry=7 <-- DISAGREE -> The bridge disagrees with the registry, and this process is not SYSTEM. -> Every bridge value equals its documented default while the registry holds different numbers. That is the exact signature of the silent-defaults fallback. # 2 / 0 / 7 / 2 are the DOCUMENTED DEFAULTS from the Policy CSP - Update reference. # The query returned success. No exception. No warning. No indication whatsoever # that you were handed a fabrication instead of this device's configuration.

The event log is the other half of the evidence, and it needs the same discipline: counts are a measurement at a moment, not a device property. Between successive reads in this series the Event 26 count on this channel moved from 210 to 412 to 555 to 1,815. Quote what you measured and when.

Microsoft-Windows-WindowsUpdateClient/Operational — 2,176 records at time of measurement, most recent 400 examined
Event IDMessage (verbatim, as observed)What it tells you
26"Windows Update successfully found N updates."344 of the 400 examined. Beware: "found 0 updates" is emitted identically by a fully-patched device and by a device excluded from everything by a stale pin. On its own it is not diagnostic.
41"An update was downloaded."55 of the 400 examined. Proves the transfer path works, which separates a download problem from a scan problem and from an install problem.
25"Windows Update failed to check for updates with error 0x80240438."1 in the window, dated 31 July 2026. Note carefully: 0x80240438 is not enumerated in Microsoft's published Windows Update error list. It has been observed repeatedly on this device. Record it; do not assert a meaning for it.
31"Windows Update failed to download an update."Exactly 2 across the entire channel, on 23 and 24 July 2026 — against 55 successful Event 41 downloads in the last 400 records alone. Rare enough that its presence is signal rather than noise, and it separates a transfer failure from a scan failure cleanly.
Setup — 2,511 records at time of measurement, most recent 400 examined
Event IDMessage (verbatim, as observed)What it tells you
2"Package <KB> was successfully changed to the Staged state."240 of the 400 examined. Staged is not Installed. A wall of Event 2 with no Event 3 means packages are landing but not committing.
4"A reboot is necessary before package KB5120708 can be changed to the Installed state."3 in the window, on 19 August 2026. This is the event that corroborates a pending-reboot verdict from the registry. When the registry says clear and Event 4 says otherwise, one of them is stale — check the timestamps.
1013"Initiating system store corruption detection and repair. Detection Only: 1, Automatically Triggered: 0."The start of a component-store scan. Detection Only: 1 is what distinguishes a ScanHealth from a RestoreHealth — the single most useful field in the pair.
1014"System store corruption detection and repair has completed. Status: 0x0, Total instances of corruption found: 0, total instances of corruption repaired: 0."The completion half. Measured 21 August 2026, four minutes after its 1013. Zero found, zero repaired — which is the evidence that lets you stop chasing a corruption theory.

Finally, the binaries, because half of the false corruption reports in this space come from someone looking for a file in the wrong directory and concluding it is missing.

BinaryRoleWhere it lives, and the measured version
wuaueng.dllThe Windows Update Agent engine. It is the ServiceDll for wuauserv, which runs as svchost.exe -k netsvcs -p under LocalSystem.C:\Windows\System32. Version 1509.2607.1012.0 — versioned entirely separately from the OS build 10.0.26200.9168. That mismatch is normal, not drift.
wuapi.dllThe Windows Update client COM API — what UpdateSession and UpdateServiceManager resolve to.C:\Windows\System32, version 1509.2607.1012.0, matching the engine.
TrustedInstaller.exeThe servicing host. Its FileDescription reads "Windows Modules Installer", which is the service display name, not a different component.C:\Windows\servicing, version 10.0.26100.7019. That folder holds only TrustedInstaller.exe, CbsApi.dll, CbsMsg.dll and wrpintapi.dll.
poqexec.exePrimitive Operations Queue Executor — runs the boot-time file operations the servicing stack queued.C:\Windows\System32, version 10.0.26100.9156, which is the servicing-stack version. Writes C:\Windows\Logs\CBS\poqexec.log.
MoUsoCoreWorker.exeUpdate Session Orchestrator worker — drives the scan, download, install and restart sequence.C:\Windows\UUS\amd64, not System32. UUS is the Unified Update Stack and is serviced independently of the OS build.
cbscore.dllThe CBS core the servicing stack loads. Its version is the servicing-stack version.Not System32 — the versioned WinSxS servicing-stack component directory, alongside TiWorker.exe and wcp.dll. Confirmed from a real "Loaded Servicing Stack" line in CBS.log.
usocore.dllFrequently cited in community guidance as the orchestrator core.Does not exist in System32 or SysWOW64 on 26200.9168. Confirmed absent three times. The real System32 USO files are usosvc.dll, usoapi.dll, usodocked.dll and usocoreps.dll.

The fix: one read-only collector, and every trap handled in code

The "fix" in this post changes nothing on the device. That is deliberate and it is the whole argument of the series: the correct first action on a patching failure is to collect, not to remediate. So the deliverable is a collector.

Tip: the script is Get-WindowsPatchingEvidence.ps1, in the repository at github.com/Imran76Awan/Windows-Patching-Scripts. It is 100% read-only: no Set-, Remove-, Stop-, Start-, Restart- or Rename- cmdlet touching device state, no service control, no folder renames, no wuauclt, no usoclient, no DISM repair switches. The only thing it ever writes is the HTML report you explicitly ask for. Pass -VerifyReadOnly and it will audit its own source and tell you so.

Follow this as a numbered path. Each step is a read, and each step is designed so that the answer cannot be a silent false negative.

  1. Establish what the device really is. Read CurrentBuild and UBR from the registry and report them as one paired value. Print [Environment]::OSVersion.Version and Win32_OperatingSystem.Version beside them so the gap is visible rather than assumed. Flag ProductName and a BuildLabEx that disagrees with CurrentBuild. If you get this wrong nothing downstream is meaningful, because you are comparing the device against the wrong baseline.
  2. Settle the pending-reboot question across every source. CBS markers via GetSubKeyNames(); the Windows Update client's Auto Update\RebootRequired key, which genuinely is a key so Test-Path is correct there; PendingFileRenameOperations as a specific value, counted; WinSxS\pending.xml as a file; and a computer-rename check comparing ActiveComputerName against ComputerName. Report each source separately with the method used. If a reboot is pending, stop — everything else queues behind it.
  3. Check the services against an expected steady state, not against "Running". trustedinstaller and msiserver Stopped/Manual while idle is correct. A collector that flags them generates a false alarm on every healthy device in the fleet, which is how people learn to ignore the tool.
  4. Read both policy hives and print them together. Classic hive with ValueCount and the named legacy AU values; MDM hive with the metadata siblings stripped out so the policy count is real. Compare the two counts and say so out loud when they diverge.
  5. Read the agent's resolved state, and derive the management verdict from evidence. Print all eighteen PolicyState values including IsWUfBConfigured — then build the verdict from the deferral state and the policy values actually present, and say explicitly in the output that the flag was not used as a test.
  6. Only query the MDM bridge deliberately, and only trust it as SYSTEM. The collector keeps this behind -IncludeMdmBridge, checks WindowsIdentity.IsSystem, compares every returned value against the registry, and labels the whole section untrusted when the context is wrong.
  7. Compare the inventory against an honest denominator. Get-HotFix returned 4 rows. The CBS Packages subkey count on the same device is 7,490. Win32_QuickFixEngineering returns only Component Based Servicing updates, so the gap is expected — but it is why Get-HotFix is the wrong basis for a compliance report.
  8. Prove the legacy last-scan check is dead before you rely on its absence. Test all three Auto Update\Results keys and report their non-existence as a finding, not as "never scanned". Use the event channel for scan evidence instead.
  9. Read the restart-suppression surface, including the parts policy cannot see. Active hours live in ...\WindowsUpdate\UX\Settings, are user-chosen, and are invisible to a policy-key audit. Measured here: ActiveHoursStart=8, ActiveHoursEnd=0 — a 16-hour daily window in which automatic restarts are suppressed.
  10. Emit findings with severities, and distinguish "clean" from "unknown". Anything the collector could not read goes into a separate gap list and forces exit code 2, because a section that failed to read must never render as a section that came back clean.

The mechanism that makes all of this possible is small: three helper functions that refuse to collapse three states into one boolean.

Get-WindowsPatchingEvidence.ps1 — the three helpers that stop the lying
function Get-RegKeyFacts { # Returns the STATE of a key, never a bare boolean. This is the shape every # policy-hive check consumes, because "the key exists" is not evidence. param([string] $Path) $result = [pscustomobject]@{ Path = $Path; Exists = $false; ValueCount = 0 SubKeyCount = 0; ValueNames = @(); SubKeyNames = @() } if (-not (Test-Path -LiteralPath $Path)) { return $result } $key = Get-Item -LiteralPath $Path -ErrorAction Stop $result.Exists = $true $result.ValueCount = [int]$key.ValueCount $result.SubKeyCount = [int]$key.SubKeyCount $result.ValueNames = @($key.Property) return $result } function Get-RegValue { # Distinguishes the THREE states that matter: present with data, present but # empty, absent. Collapsing those into a boolean is where wrong verdicts # come from. param([string] $Path, [string] $Name) ... if ($null -eq $prop.Value -or ([string]$prop.Value).Trim().Length -eq 0) { $result.IsEmpty = $true $result.Display = '<present but EMPTY>' } ... } function Get-HklmSubKeyNames { # The call Microsoft's own CheckForPendingReboot.ps1 makes. The only correct # way to test the CBS reboot markers - they are subkeys, so no value-based # test can ever see them. param([string] $SubKeyPath) $base = [Microsoft.Win32.Registry]::LocalMachine $key = $base.OpenSubKey($SubKeyPath) if ($null -eq $key) { return $null } try { return @($key.GetSubKeyNames()) } finally { $key.Close() } } # Everything else in the collector is built on these three. Get-RegKeyFacts for # "how much is in this key", Get-RegValue for "what does this one value say", # Get-HklmSubKeyNames for the markers that are subkeys. No Test-Path verdicts.

Two switches matter operationally. -Redact masks the machine name, every GUID in every emitted string, and the hardware serial, so the report can be attached to a public forum post or a vendor case — values are masked, never removed, because "field is populated but hidden" and "field is absent" are different pieces of evidence. -OutputHtml <path> writes a self-contained styled report: no external CSS, no web fonts, no scripts, no images, so it renders identically offline, from a USB stick, or behind a proxy that blocks outbound requests from a local HTML file.

Proof it worked: the run, the self-audit, and two bugs that parsed clean

The script was saved to disk and run four times: plain, with -VerifyReadOnly, with -IncludeMdmBridge, and with -Redact -OutputHtml. Here is the tail of the real run.

.\Get-WindowsPatchingEvidence.ps1 -VerifyReadOnly -OutputHtml .\evidence-full.html
Windows Patching Evidence Collector (read-only) Generated : 2026-08-23 12:20:01 PowerShell : 5.1.26100.9168 Mode : COLLECT ONLY. Nothing on this device is changed. Context : elevated administrator (NOT SYSTEM) ------------------------------------------------------------------------------ 0. Read-only self-audit ------------------------------------------------------------------------------ Code tokens searched : 8517 Mutating patterns searched : 21 Device-mutating commands found : 0 -> Comment tokens are excluded, so the help text above does not match itself. -> The only write this script performs is the -OutputHtml report file. ------------------------------------------------------------------------------ Findings ------------------------------------------------------------------------------ [WARNING] The classic Group Policy hive holds 2 value(s) while the MDM hive holds 23 policy value(s). Auditing only the classic hive on this device would miss most of the configuration in force. [WARNING] Release pin mismatch: TargetReleaseVersionInfo=24H2 while the installed DisplayVersion is 25H2. The pin has been overtaken, so it is not what is holding this device back. [WARNING] 6 failure or corruption-class event(s) found in the window examined. [WARNING] Active hours span 16 hours (8 to 0). Automatic restarts are suppressed for most of the day. Invisible to a policy-key audit. [NOTE] ProductName reads "Windows 10 Enterprise" although CurrentBuild is 26200. [NOTE] BuildLabEx build (26100) does not match CurrentBuild (26200). [NOTE] The legacy ...\WindowsUpdate\AU key exists with ValueCount 0. [NOTE] Delivery Optimization: the classic policy key does not exist while 17 DO policies are in force through the MDM hive. [NOTE] IsWUfBConfigured=0 was read at the same instant as IsDeferralIsActive=1 and QualityUpdatesDeferralInDays=7. [NOTE] The legacy Auto Update\Results\{Detect,Download,Install} keys do not exist on this build. Blocking: 0 Warning: 4 Note: 6 HTML report written: ...\evidence-full.html Size: 20702 bytes, self-contained, no external references. Done. Read-only. Exit code 0.

Ten findings on a device that every dashboard reports as healthy. None of them is a fault. All of them are things you would want to know before you started changing anything — and four of them are cases where a naive script would have produced a different, wrong answer.

The same run with -Redact added produced a 20,774-byte report against the plain run's 20,702. Both files were then checked mechanically: DOCTYPE present; every tag balanced, at 134 tr and 298 td elements each with matching closers; zero <script>, <img> and <link> elements; zero @import; zero url(); no unresolved template variables anywhere in the output; and exactly one outbound hyperlink in the whole document, the repository link in the footer. In the redacted copy the machine name, the hardware serial and all six enrollment GUIDs were confirmed absent by string search, and the same strings were confirmed present in the unredacted copy — so the switch is doing work rather than being decorative.

Gotcha: [System.Management.Automation.Language.Parser]::ParseFile() reporting zero errors does not mean your script works. Both of the bugs below parsed completely clean and failed at run time, and both were only found by saving the file and running it from disk. If you take one operational habit from this post, take that one: parse-clean is not tested.

The first bug is a genuine PowerShell 5.1 quirk that I had not met before. Wrapping a System.Collections.Generic.List[object] in the array subexpression operator throws:

PowerShell 5.1.26100.9168 — parses clean, fails at run time
PS C:\> $l = New-Object System.Collections.Generic.List[object] PS C:\> $l.Add('hello') PS C:\> $x = @($l) Argument types do not match + CategoryInfo : OperationStopped: (:) [], ArgumentException + FullyQualifiedErrorId : System.ArgumentException PS C:\> # Same code, one type parameter different: PS C:\> $p = New-Object System.Collections.Generic.List[psobject] PS C:\> $p.Add([pscustomobject]@{ A = 1 }) PS C:\> @($p).Count 1 # List[object] breaks under @(); List[psobject] and List[string] are fine. # The parser has no opinion about any of this. The first run of the collector # threw this four times - once per accumulator - while still printing a # complete-looking report, which is exactly the failure mode this whole post # is about: plausible output, silently missing data.

The second bug is funnier and more instructive. The read-only self-audit originally worked by reading its own source file line by line and searching for mutating command names. It reported three violations on its first real run:

Self-audit, first version: Device-mutating commands found : 3net stop, wuauclt and usoclient, all three on line 15. Line 15 is inside the script's own help block, in the sentence that promises the script contains no net stop, no wuauclt and no usoclient. The audit had flagged its own documentation, raised a Blocking finding, and set exit code 1 on a script that was in fact perfectly clean.

That is the empty-key trap in a different costume: a check that answers "does this text appear somewhere in the file" when the question was "does this file execute this command". The fix is the same in kind as every other fix in this post — stop pattern-matching the surface, parse the structure. The audit now tokenises itself with the PowerShell parser, discards every Comment token, and searches only real code. That is the 8,517 code tokens in the output above, and it is why the result is now 0.

Where this leaves you. Run the collector first, on any device that is not patching, before you form a theory and long before you change anything. Keep the HTML report on the ticket — it is the only record of what the device looked like before anyone touched it. Then take the findings to post 40's routing table and work the symptom. The collector will not tell you the root cause. It will stop you being confidently wrong about the starting conditions, which is where most of the wasted hours in this space actually go.

Forty posts. One recurring lesson, discovered independently six times: on Windows, the question "is this configured" almost never has a boolean answer, and a script that pretends it does will lie to you politely and at scale. Ask for the state. Print the counts. Name the method. And run the thing before you ship it.

References

Was this post helpful?
React below — no account needed
Share this post
LinkedIn X / Twitter Reddit Bluesky

More from EndpointWeekly

Windows Update
The Windows patching triage decision tree: which log, which key,…
Route each patching symptom to the one evidence source that answers it. Then learn the…
Windows Update
"Pending reboot" has at least five sources and your tooling…
Windows has no single reboot-pending flag - it has five independent markers owned by…
Windows Update
Delivery Optimization says it is peering and your WAN link says…
DO falls back to the CDN silently, with no error and no Event Viewer channel to read.…