This guide continues the Active Directory at Scale series and is the natural follow-up to Group Policy as code. It assumes the ActiveDirectory PowerShell module, PowerShell 7, and delegated rights on CN=SOM,CN=WMIPolicy,CN=System,<domainDN> (Domain Admins by default; you can delegate Create/Delete msWMI-Som objects to a non-DA group).

A GPO that doesn't apply where you expect is almost never a GPO problem. It's a WMI filter silently rejecting on a client whose CIM data doesn't match the WQL. The GPMC happily attaches the filter, gpresult quietly lists it as "denied (WMI Filter)", and a registry change you swore you deployed never lands because Win32_OperatingSystem.ProductType = 3 evaluates false on every Server Core box in the fleet.

WMI filters are the silent half of Group Policy. They live in a different LDAP container (CN=SOM,CN=WMIPolicy,CN=System,...), use a hand-rolled length-prefixed encoding in msWMI-Parm2, ship with no Microsoft cmdlets (only third-party modules or raw Set-ADObject), and refuse to validate the WQL syntax before binding it to a GPO. The result is the most common "ghost" failure mode in a managed AD: GPO links look correct, WMI filter looks correct, and nothing applies.

This post brings WMI filters under the same Get-Test-Set discipline as the rest of the series. A YAML file describes the filter set, a reconciler creates / updates / links them idempotently, and a parser handles the msWMI-Parm2 encoding so you never look at it again.

What a WMI Filter Actually Is

A WMI filter is an AD object of class msWMI-Som (System of Management). It lives at:

CN={<filter-guid>},CN=SOM,CN=WMIPolicy,CN=System,DC=corp,DC=example,DC=com

The interesting attributes:

Attribute Holds
msWMI-Name Display name ("Workstations only")
msWMI-Author Author identity (CORP\alice or service principal)
msWMI-ID A GUID in {...} form. This is what GPOs reference.
msWMI-Parm1 Description string
msWMI-Parm2 The encoded WQL queries (one or more, with namespaces)
msWMI-ChangeDate / msWMI-CreationDate UTC strings, format yyyyMMddHHmmss.000000-000

A GPO binds to a filter via its own gPCWQLFilter attribute, which holds a string like:

[corp.example.com;{72E84E6E-C2C0-44E3-B7BA-9F4A5B1F2C9A};0]

Three semicolon-separated fields: domain, filter GUID (in braces), and a flag (0). One filter, one GPO link per GPO. Multiple GPOs can reference the same filter.

The link is by GUID, not name. Renaming a filter doesn't break anything. Deleting and recreating a filter with the same name does every linked GPO suddenly evaluates against no filter and applies to everything, which is the kind of mistake that earns a postmortem.

The Encoding Trap msWMI-Parm2

For a single query, msWMI-Parm2 looks like:

1;3;10;55;WQL;root\cimv2;SELECT * FROM Win32_OperatingSystem WHERE ProductType = "1";

Semicolon-separated, but the numbers aren't decorative. The format is:

<n>;<lenNS_1>;<lenQ_1>;<lenS_1>;<type_1>;<ns_1>;<query_1>;...;<lenNS_n>;<lenQ_n>;<lenS_n>;<type_n>;<ns_n>;<query_n>;
  • n total query count
  • lenNS length of the namespace string in chars
  • lenQ length of the type string (WQL = 3)
  • lenS length of the query string in chars
  • type literal WQL (the only supported type)
  • ns namespace (root\cimv2, root\rsop\computer, etc.)
  • query the WQL itself
  • Every record terminated with a trailing ;

Get the lengths wrong by one character and AD accepts the object, GPMC shows the filter, the client agent fails to parse it, and the filter evaluates as true (or false, depending on agent version no, really). That's why hand-editing msWMI-Parm2 in ADSI Edit is a known way to break Group Policy across a forest.

We never hand-edit it. We have a small encoder.

function ConvertTo-WmiParm2
{
    <#
    .SYNOPSIS
        Encodes one or more WQL queries into the msWMI-Parm2 wire format.

    .DESCRIPTION
        Produces the exact length-prefixed semicolon-separated string that AD
        and the Group Policy client expect. Use this instead of constructing
        the string by hand - off-by-one length fields are silently accepted
        by AD and silently break the filter at evaluation time.

    .PARAMETER Query
        One or more hashtables with Namespace and Wql keys.

    .EXAMPLE
        ConvertTo-WmiParm2 -Query @(
            @{ Namespace = 'root\cimv2'; Wql = 'SELECT * FROM Win32_OperatingSystem WHERE ProductType = "1"' }
        )

    .OUTPUTS
        System.String
    #>
    [CmdletBinding()]
    [OutputType([string])]
    param
    (
        [Parameter(Mandatory = $true)]
        [hashtable[]]
        $Query
    )

    begin
    {
        Write-Verbose -Message '[ConvertTo-WmiParm2] Begin'
    }

    process
    {
        $sb = [System.Text.StringBuilder]::new()
        $null = $sb.Append($Query.Count).Append(';')

        foreach ($q in $Query)
        {
            $ns  = $q.Namespace
            $wql = $q.Wql

            $null = $sb.Append($ns.Length).Append(';')
            $null = $sb.Append(3).Append(';')                  # 'WQL' is 3 chars
            $null = $sb.Append($wql.Length).Append(';')
            $null = $sb.Append('WQL').Append(';')
            $null = $sb.Append($ns).Append(';')
            $null = $sb.Append($wql).Append(';')
        }

        $sb.ToString()
    }

    end
    {
        Write-Verbose -Message '[ConvertTo-WmiParm2] End'
    }
}

And a parser, because we need to read live filters during Test-*:

function ConvertFrom-WmiParm2
{
    <#
    .SYNOPSIS
        Decodes an msWMI-Parm2 string back into an array of query records.

    .DESCRIPTION
        Inverse of ConvertTo-WmiParm2. Returns Namespace/Wql pairs so the
        Test-* function can compare current and desired without dealing with
        the encoding.

    .PARAMETER Parm2
        The raw msWMI-Parm2 string read from AD.

    .OUTPUTS
        System.Management.Automation.PSCustomObject[]
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string]
        $Parm2
    )

    begin
    {
        Write-Verbose -Message '[ConvertFrom-WmiParm2] Begin'
    }

    process
    {
        $tokens = $Parm2.Split(';')
        $count  = [int]$tokens[0]
        $result = New-Object -TypeName 'System.Collections.Generic.List[pscustomobject]'

        $i = 1

        for ($q = 0; $q -lt $count; $q++)
        {
            $lenNs    = [int]$tokens[$i++]
            $lenType  = [int]$tokens[$i++]
            $lenQuery = [int]$tokens[$i++]
            $type     = $tokens[$i++]
            $ns       = $tokens[$i++]
            $wql      = $tokens[$i++]

            if ($type -ne 'WQL')
            {
                throw ('Unsupported filter type in msWMI-Parm2: {0}' -f $type)
            }

            if ($ns.Length -ne $lenNs)
            {
                Write-Warning -Message ('Namespace length mismatch (declared {0}, actual {1}); filter may be corrupt' -f $lenNs, $ns.Length)
            }

            if ($wql.Length -ne $lenQuery)
            {
                Write-Warning -Message ('WQL length mismatch (declared {0}, actual {1}); filter may be corrupt' -f $lenQuery, $wql.Length)
            }

            $result.Add([pscustomobject] @{
                Namespace = $ns
                Wql       = $wql
            })
        }

        $result.ToArray()
    }

    end
    {
        Write-Verbose -Message '[ConvertFrom-WmiParm2] End'
    }
}

The parser deliberately warns rather than throws on length mismatch. A filter created in GPMC and then edited in ADSI Edit will occasionally have a one-off length field, and the agent tolerates it. We surface the warning during reconciliation so it shows up in the CI log, but we don't refuse to reconcile.

The State File

A single YAML stanza per filter, with stable IDs reused from the AD-as-code pattern:

wmiFilters:
  - id: wmi.workstation-only
    name: Workstations only
    description: Matches Windows 10/11 workstations (ProductType=1)
    queries:
      - namespace: root\cimv2
        wql: SELECT * FROM Win32_OperatingSystem WHERE ProductType = "1"
    linkedGpos:
      - gpo.security.workstation-baseline
      - gpo.endpoint.bitlocker

  - id: wmi.server-not-dc
    name: Member servers (not DCs)
    description: ProductType=3 excludes DCs (ProductType=2)
    queries:
      - namespace: root\cimv2
        wql: SELECT * FROM Win32_OperatingSystem WHERE ProductType = "3"
    linkedGpos:
      - gpo.server.audit-baseline

  - id: wmi.x64-only
    name: 64-bit OS only
    description: Skip 32-bit edge cases
    queries:
      - namespace: root\cimv2
        wql: SELECT * FROM Win32_OperatingSystem WHERE OSArchitecture = "64-bit"
    linkedGpos: []

Two queries on the same filter AND together at evaluation; if you need OR, write it inline in WQL (WHERE ProductType=1 OR ProductType=3). Empty linkedGpos is valid the filter exists but isn't bound to anything (useful when staging).

The Get-Test-Set Triple

Get-WmiFilterState

function Get-WmiFilterState
{
    <#
    .SYNOPSIS
        Returns the current state of a WMI filter by stable id.

    .DESCRIPTION
        Looks up the filter under CN=SOM,CN=WMIPolicy,... by stable id
        stamped onto extensionAttribute15. A missing filter is not an
        error - returns Exists = $false. Decodes msWMI-Parm2 into a
        structured Queries array so callers never touch the encoding.

    .PARAMETER Id
        Stable id of the WMI filter.

    .PARAMETER DomainDn
        Distinguished name of the domain (DC=corp,DC=example,DC=com).

    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $Id,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $DomainDn
    )

    begin
    {
        Write-Verbose -Message ('[Get-WmiFilterState] Begin')
    }

    process
    {
        $somDn = ('CN=SOM,CN=WMIPolicy,CN=System,{0}' -f $DomainDn)

        $findParams = @{
            LDAPFilter  = "(&(objectClass=msWMI-Som)(extensionAttribute15=$Id))"
            SearchBase  = $somDn
            Properties  = 'msWMI-Name', 'msWMI-Parm1', 'msWMI-Parm2', 'msWMI-ID',
                          'msWMI-Author', 'extensionAttribute15'
            ErrorAction = 'SilentlyContinue'
        }

        $filter = Get-ADObject @findParams | Select-Object -First 1

        if (-not $filter)
        {
            return [pscustomobject] @{
                Exists      = $false
                Id          = $Id
                WmiGuid     = $null
                Name        = $null
                Description = $null
                Queries     = @()
            }
        }

        $queries = if ($filter.'msWMI-Parm2')
        {
            ConvertFrom-WmiParm2 -Parm2 $filter.'msWMI-Parm2'
        }
        else
        {
            @()
        }

        [pscustomobject] @{
            Exists      = $true
            Id          = $Id
            WmiGuid     = $filter.'msWMI-ID'
            DN          = $filter.DistinguishedName
            Name        = $filter.'msWMI-Name'
            Description = $filter.'msWMI-Parm1'
            Queries     = $queries
        }
    }

    end
    {
        Write-Verbose -Message ('[Get-WmiFilterState] End')
    }
}

Test-WmiFilterCompliance

function Test-WmiFilterCompliance
{
    <#
    .SYNOPSIS
        Reads a WMI filter's current state and classifies drift against Desired.

    .DESCRIPTION
        Calls Get-WmiFilterState internally and returns the current state on
        the result object so Set-* does not have to re-read. Queries are
        compared as ordered sets - reordering counts as drift to keep the
        WQL evaluation order deterministic.

    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [pscustomobject]
        $Desired,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $DomainDn
    )

    begin
    {
        Write-Verbose -Message '[Test-WmiFilterCompliance] Begin'
    }

    process
    {
        $current = Get-WmiFilterState -Id $Desired.Id -DomainDn $DomainDn
        $drifts  = New-Object -TypeName 'System.Collections.Generic.List[string]'

        if (-not $current.Exists)
        {
            $drifts.Add('Filter does not exist')

            return [pscustomobject] @{
                Compliant = $false
                Drifts    = $drifts.ToArray()
                Current   = $current
            }
        }

        if ($current.Name -ne $Desired.Name)
        {
            $drifts.Add(('Name: {0} != {1}' -f $current.Name, $Desired.Name))
        }

        if (($current.Description ?? '') -ne ($Desired.Description ?? ''))
        {
            $drifts.Add('Description drift')
        }

        if ($current.Queries.Count -ne $Desired.Queries.Count)
        {
            $drifts.Add(('Query count: {0} != {1}' -f $current.Queries.Count, $Desired.Queries.Count))
        }
        else
        {
            for ($i = 0; $i -lt $current.Queries.Count; $i++)
            {
                if ($current.Queries[$i].Namespace -ne $Desired.Queries[$i].Namespace -or
                    $current.Queries[$i].Wql       -ne $Desired.Queries[$i].Wql)
                {
                    $drifts.Add(('Query[{0}] differs' -f $i))
                }
            }
        }

        [pscustomobject] @{
            Compliant = $drifts.Count -eq 0
            Drifts    = $drifts.ToArray()
            Current   = $current
        }
    }

    end
    {
        Write-Verbose -Message '[Test-WmiFilterCompliance] End'
    }
}

Set-WmiFilterCompliance

The mutating function. Creating an msWMI-Som from scratch requires generating a fresh GUID, building the LDAP attributes by hand, and stamping the stable id we'll search by next time. Every mutation is wrapped in try/catch the same way every other reconciler in the series is.

function Set-WmiFilterCompliance
{
    <#
    .SYNOPSIS
        Converges a WMI filter to match the desired-state object.

    .DESCRIPTION
        Calls Test-WmiFilterCompliance once and acts only on drift.
        Creates the msWMI-Som object directly via New-ADObject when missing
        (no Microsoft cmdlet exists for this). Every AD mutation is guarded
        by ShouldProcess and wrapped in try/catch with -ErrorAction Stop so
        non-terminating errors do not silently leave the filter half-applied.

    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    [OutputType([pscustomobject])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [pscustomobject]
        $Desired,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)]
        [string]
        $DomainDn
    )

    begin
    {
        Write-Verbose -Message ('[Set-WmiFilterCompliance] Begin')
    }

    process
    {
        $ErrorActionPreference = 'Stop'

        $test    = Test-WmiFilterCompliance -Desired $Desired -DomainDn $DomainDn
        $current = $test.Current
        $actions = New-Object -TypeName 'System.Collections.Generic.List[string]'
        $errors  = New-Object -TypeName 'System.Collections.Generic.List[string]'

        if ($test.Compliant)
        {
            return [pscustomobject] @{
                Id      = $Desired.Id
                Changed = $false
                Actions = @()
                Errors  = @()
            }
        }

        $parm2  = ConvertTo-WmiParm2 -Query $Desired.Queries
        $now    = (Get-Date).ToUniversalTime().ToString('yyyyMMddHHmmss.000000-000')
        $author = "{0}@{1}" -f $env:USERNAME, $env:USERDNSDOMAIN
        $somDn  = ('CN=SOM,CN=WMIPolicy,CN=System,{0}' -f $DomainDn)

        if (-not $current.Exists)
        {
            $newGuid = '{' + ([guid]::NewGuid().ToString().ToUpper()) + '}'
            $cn      = $newGuid

            if ($PSCmdlet.ShouldProcess($Desired.Name, 'Create WMI filter'))
            {
                $newParams = @{
                    Name            = $cn
                    Type            = 'msWMI-Som'
                    Path            = $somDn
                    OtherAttributes = @{
                        'msWMI-Name'           = $Desired.Name
                        'msWMI-Parm1'          = $Desired.Description
                        'msWMI-Parm2'          = $parm2
                        'msWMI-ID'             = $newGuid
                        'msWMI-Author'         = $author
                        'msWMI-CreationDate'   = $now
                        'msWMI-ChangeDate'     = $now
                        'extensionAttribute15' = $Desired.Id
                        'showInAdvancedViewOnly' = $true
                        'instanceType'         = 4
                    }
                    ErrorAction     = 'Stop'
                }

                try
                {
                    $null = New-ADObject @newParams
                    $actions.Add('Created filter')
                    $current = Get-WmiFilterState -Id $Desired.Id -DomainDn $DomainDn
                }
                catch [Microsoft.ActiveDirectory.Management.ADException]
                {
                    $errors.Add(('New-ADObject failed for {0}: {1}' -f $Desired.Name, $_.Exception.Message))
                    throw
                }
                catch
                {
                    $errors.Add(('Unexpected error creating {0}: {1}' -f $Desired.Name, $_.Exception.Message))
                    throw
                }
            }
        }
        else
        {
            # Update path: build the replace set, write once.
            $replace = @{}

            if ($current.Name -ne $Desired.Name)
            {
                $replace['msWMI-Name'] = $Desired.Name
                $actions.Add('Name updated')
            }

            if (($current.Description ?? '') -ne ($Desired.Description ?? ''))
            {
                $replace['msWMI-Parm1'] = $Desired.Description
                $actions.Add('Description updated')
            }

            $desiredParm2 = $parm2
            $currentParm2 = if ($current.Queries.Count)
            {
                ConvertTo-WmiParm2 -Query $current.Queries
            }
            else
            {
                ''
            }

            if ($currentParm2 -ne $desiredParm2)
            {
                $replace['msWMI-Parm2']      = $desiredParm2
                $replace['msWMI-ChangeDate'] = $now
                $actions.Add('Queries updated')
            }

            if ($replace.Count -gt 0 -and
                $PSCmdlet.ShouldProcess($current.DN, 'Update WMI filter attributes'))
            {
                try
                {
                    Set-ADObject -Identity $current.DN -Replace $replace -ErrorAction Stop
                }
                catch [Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException]
                {
                    $errors.Add(('Filter {0} disappeared mid-run; replication or concurrent delete' -f $current.DN))
                    throw
                }
                catch [Microsoft.ActiveDirectory.Management.ADException]
                {
                    $errors.Add(('Set-ADObject failed for {0}: {1}' -f $current.DN, $_.Exception.Message))
                    throw
                }
                catch
                {
                    $errors.Add(('Unexpected error updating {0}: {1}' -f $current.DN, $_.Exception.Message))
                    throw
                }
            }
        }

        [pscustomobject] @{
            Id      = $Desired.Id
            Changed = $actions.Count -gt 0
            Actions = $actions.ToArray()
            Errors  = $errors.ToArray()
        }
    }

    end
    {
        Write-Verbose -Message ('[Set-WmiFilterCompliance] End')
    }
}

The msWMI-ChangeDate update on a query-only change is deliberate. The Group Policy client uses that timestamp as a cache key; without bumping it, some agents will keep evaluating the old filter for up to two policy refresh cycles after the update.

Linking Filters to GPOs

Once a filter exists, it has to be bound to one or more GPOs by writing into gPCWQLFilter on the GPO object. The format:

[<domain-fqdn>;<filter-guid-in-braces>;0]

The link reconciler runs after both the WMI filter and the GPO exist. It looks up the filter by stable id, looks up the GPO by stable id (using the AD-as-code lookup), and writes the link string:

function Set-WmiFilterLinkCompliance
{
    <#
    .SYNOPSIS
        Binds or unbinds a WMI filter to / from a target GPO.

    .DESCRIPTION
        Reconciles gPCWQLFilter on the GPO. Use 'None' as the FilterId to
        clear the filter (sets gPCWQLFilter to empty, meaning "apply
        unconditionally").

    .PARAMETER GpoStableId
        Stable id of the GPO to bind to.

    .PARAMETER FilterStableId
        Stable id of the filter, or 'None' to unbind.

    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>
    [CmdletBinding(SupportsShouldProcess = $true)]
    param
    (
        [Parameter(Mandatory = $true)] [string] $GpoStableId,
        [Parameter(Mandatory = $true)] [string] $FilterStableId,
        [Parameter(Mandatory = $true)] [string] $DomainDn,
        [Parameter(Mandatory = $true)] [string] $DomainFqdn
    )

    begin
    {
        Write-Verbose -Message '[Set-WmiFilterLinkCompliance] Begin'
    }

    process
    {
        $ErrorActionPreference = 'Stop'

        $errors = New-Object -TypeName 'System.Collections.Generic.List[string]'

        # Resolve the GPO via the AD-as-code id lookup
        $gpoObj = Find-AdObjectById -Id $GpoStableId -SearchBase $DomainDn

        if (-not $gpoObj)
        {
            throw ('GPO not found by id: {0}' -f $GpoStableId)
        }

        $desiredLink = if ($FilterStableId -eq 'None')
        {
            ''
        }
        else
        {
            $filter = Get-WmiFilterState -Id $FilterStableId -DomainDn $DomainDn

            if (-not $filter.Exists)
            {
                throw ('WMI filter not found by id: {0}' -f $FilterStableId)
            }

            '[{0};{1};0]' -f $DomainFqdn, $filter.WmiGuid
        }

        $currentLink = (Get-ADObject -Identity $gpoObj.DistinguishedName -Properties gPCWQLFilter).gPCWQLFilter ?? ''

        if ($currentLink -eq $desiredLink)
        {
            return [pscustomobject] @{
                GpoStableId    = $GpoStableId
                FilterStableId = $FilterStableId
                Changed        = $false
                Errors         = @()
            }
        }

        if ($PSCmdlet.ShouldProcess($gpoObj.DistinguishedName, ('Set gPCWQLFilter -> {0}' -f $desiredLink)))
        {
            try
            {
                if ($desiredLink)
                {
                    Set-ADObject -Identity $gpoObj.DistinguishedName -Replace @{ gPCWQLFilter = $desiredLink } -ErrorAction Stop
                }
                else
                {
                    Set-ADObject -Identity $gpoObj.DistinguishedName -Clear gPCWQLFilter -ErrorAction Stop
                }
            }
            catch [Microsoft.ActiveDirectory.Management.ADException]
            {
                $errors.Add(('Set-ADObject failed on {0}: {1}' -f $gpoObj.DistinguishedName, $_.Exception.Message))
                throw
            }
            catch
            {
                $errors.Add(('Unexpected error linking filter to {0}: {1}' -f $gpoObj.DistinguishedName, $_.Exception.Message))
                throw
            }
        }

        [pscustomobject] @{
            GpoStableId    = $GpoStableId
            FilterStableId = $FilterStableId
            Changed        = $true
            Errors         = $errors.ToArray()
        }
    }

    end
    {
        Write-Verbose -Message '[Set-WmiFilterLinkCompliance] End'
    }
}

A filter that's bound to linkedGpos: [] in YAML and currently bound to two GPOs needs both unlinks. The runner enumerates the diff per filter and dispatches.

Validating the WQL Before You Ship It

Neither AD nor the Group Policy client validate WQL syntactically before applying. A typo in FROM Win32_OperatignSystem is silently accepted and the filter evaluates as false on every client. We catch this in CI by running the WQL against a real CIM endpoint before applying:

function Test-WmiFilterQuery
{
    <#
    .SYNOPSIS
        Validates that the WQL in a filter is well-formed by executing it
        against the local CIM session.

    .DESCRIPTION
        Doesn't care about the result - only that the query parses and the
        namespace exists. A parse error or unknown namespace fails the test.
        Run in CI against a Windows runner before pushing the YAML.

    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param
    (
        [Parameter(Mandatory = $true)] [string] $Namespace,
        [Parameter(Mandatory = $true)] [string] $Wql
    )

    begin
    {
        Write-Verbose -Message '[Test-WmiFilterQuery] Begin'
    }

    process
    {
        try
        {
            $null = Get-CimInstance -Namespace $Namespace -Query $Wql -ErrorAction Stop
            [pscustomobject] @{
                Namespace = $Namespace
                Wql       = $Wql
                Valid     = $true
                Reason    = ''
            }
        }
        catch [Microsoft.Management.Infrastructure.CimException]
        {
            [pscustomobject] @{
                Namespace = $Namespace
                Wql       = $Wql
                Valid     = $false
                Reason    = $_.Exception.Message
            }
        }
        catch
        {
            [pscustomobject] @{
                Namespace = $Namespace
                Wql       = $Wql
                Valid     = $false
                Reason    = ('Unexpected: {0}' -f $_.Exception.Message)
            }
        }
    }

    end
    {
        Write-Verbose -Message '[Test-WmiFilterQuery] End'
    }
}

Run this on every query in the YAML during the plan job. The runner's CIM is good enough to catch parse errors and typos; differences between client OS versions (a class that exists on Server 2022 but not Server 2016, for example) need a separate test matrix.

Putting It Together The Runner

[CmdletBinding(SupportsShouldProcess)]
param
(
    [Parameter(Mandatory = $true)]
    [ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
    [string]
    $StateFile
)

$ErrorActionPreference = 'Stop'
Import-Module ./src/ADOps/ADOps.psd1 -Force

$state      = Import-DesiredState -Path $StateFile
$domain     = (Get-ADDomain)
$domainDn   = $domain.DistinguishedName
$domainFqdn = $domain.DNSRoot

# 1. Reconcile the filters themselves
foreach ($f in $state.wmiFilters)
{
    Set-WmiFilterCompliance -Desired $f -DomainDn $domainDn
}

# 2. Reconcile the links (one row per filter -> GPO binding)
foreach ($f in $state.wmiFilters)
{
    foreach ($gpoId in $f.linkedGpos)
    {
        Set-WmiFilterLinkCompliance -GpoStableId $gpoId -FilterStableId $f.id `
            -DomainDn $domainDn -DomainFqdn $domainFqdn
    }
}

# 3. Reconcile "no filter" on GPOs that previously had one but shouldn't now.
#    Walk every GPO whose gPCWQLFilter references a filter we manage, and
#    if the YAML no longer lists that GPO in the filter's linkedGpos, unbind.
$managedGuids = $state.wmiFilters | ForEach-Object {
    (Get-WmiFilterState -Id $_.id -DomainDn $domainDn).WmiGuid
} | Where-Object { $_ }

Get-ADObject -Filter "objectClass -eq 'groupPolicyContainer'" -SearchBase $domainDn `
    -Properties gPCWQLFilter |
    Where-Object { $_.gPCWQLFilter -and ($_.gPCWQLFilter -split ';')[1] -in $managedGuids } |
    ForEach-Object {
        $guidInLink = ($_.gPCWQLFilter -split ';')[1]
        $matchedFilter = $state.wmiFilters | Where-Object {
            (Get-WmiFilterState -Id $_.id -DomainDn $domainDn).WmiGuid -eq $guidInLink
        }

        if (-not ($matchedFilter.linkedGpos -contains (
                Find-AdObjectById -Id $_.cn -SearchBase $domainDn).Id)) {
            Set-WmiFilterLinkCompliance -GpoStableId $_.cn -FilterStableId 'None' `
                -DomainDn $domainDn -DomainFqdn $domainFqdn
        }
    }

Pass 3 is the unbind sweep. Without it, removing a GPO from a filter's linkedGpos in YAML doesn't actually clear the link on the GPO it just stops actively setting it. Drift accumulates silently. The unbind sweep keeps "linked in AD" and "linked in YAML" in lockstep.

Gotchas Worth Internalizing

  • msWMI-ID is the wire identity. GPOs reference it; never reuse one. If you delete a filter and want to recreate "the same one", generate a new GUID and update every GPO link or readers of the OLD GUID will get an "orphan" reference.
  • msWMI-Parm2 is order-sensitive. Two queries [A, B] and [B, A] produce the same evaluation result but different encoded strings the reconciler treats reordering as drift to keep the on-disk form stable.
  • WMI filters evaluate on the client. A filter that works against your admin machine's WMI may evaluate differently on a domain-joined workstation with a different Win32_ComputerSystem.Domain value. Test on a representative client, not on the DC.
  • No filter == applies to everyone. Clearing gPCWQLFilter makes the GPO apply to every linked OU member. That is occasionally what you want; more often it's a regression from someone deleting the filter and not the link.
  • Replication latency. The filter object and the GPO link object live in different containers and replicate independently. After creating both in the same run, target the same DC (-Server) or expect a few minutes of "GPO without its filter" on remote DCs.
  • GPMC caches. GPMC reads the filter list at startup and caches aggressively. After a CI apply, GPMC may show stale filter names until restarted ignore the UI and read the AD object.

What to Do Next

WMI filters are the half of Group Policy nobody automates and the half that breaks most often. With a Get-Test-Set triple on msWMI-Som, an encoder for msWMI-Parm2, a parser for round-tripping, a link reconciler against gPCWQLFilter, and a Test-WmiFilterQuery gate in CI, the entire surface comes under the same discipline as the rest of the directory. No more "the GPO is linked but doesn't apply" tickets; the YAML says where it applies, and the agent agrees.

Three concrete moves to bring filters under code this week:

  1. Pick three filters from the existing GPMC tree and export them by hand. ADSI Edit -> CN=SOM,CN=WMIPolicy,... -> copy msWMI-Parm2 into the parser. The first time you see your production filter render as a clean YAML stanza is the moment you realize the encoding is the only thing standing in the way.
  2. Add Test-WmiFilterQuery to the plan job in CI. Before any apply, every query in the YAML executes against a Windows runner's WMI. Catches typos, missing namespaces, and parse errors in seconds instead of after a 90-minute Group Policy refresh cycle revealing the GPO didn't land.
  3. Wire the unbind sweep into the apply runner. Drop a GPO from a filter's linkedGpos, apply, then look at the GPO in GPMC if gPCWQLFilter is still set, the sweep isn't running. Without it, every removal is "stop adding it" not "actively remove it", which silently grows divergence between YAML and AD.

Pairs naturally with the Group Policy as code post (which manages the GPOs these filters bind to) and the AD-as-code post (whose stable-id pattern is what lets the link reconciler look up GPOs by id instead of by display name). The next post in the series tackles certificate templates which have their own weird LDAP container, their own opaque attribute encoding, and the same Get-Test-Set treatment.