The Microsoft Learn reference for assignment filter properties gives this example for cpuArchitecture:

1
(device.cpuArchitecture -in ["x64", "arm64"])

The property applies to macOS and Windows, and the example sits under both. On macOS the values are x64, arm64 and unknown. On Windows they’re amd64, x86, arm64 and unknown. Paste that example into a filter for the Windows platform and the service saves it without complaint, because validateFilter accepts any string for an enumerated property. When it evaluates the rule against an x64 Windows device, that device reports amd64, so the clause fails and the device is dropped. Your ARM devices still match. If nobody in the pilot group has an ARM laptop, the app reaches nobody, and the portal tells each device “Filters criteria are not met.”

Every other place in Intune calls that architecture x64. The Win32 app’s requirement page uses x64, and Graph’s managedDevice.processorArchitecture is x64. Only the filter wants amd64.

The only way to try a rule before assigning it is the portal’s Preview devices. It needs the tenant, an enrolled device with the right properties and a click per rule. This post covers Test-IntuneAssignmentFilter, the IntuneScriptLab command that checks a rule locally. It parses the rule the way the service accepts or refuses it and evaluates it the way the service matched two enrolled devices. Every rule behind it came from a probe against the service, so when the command says a rule is refused, the service refused that same shape.

Before anything runs

Filters act earlier than anything else the series has covered. Learn says they’re evaluated when a device enrolls, when it checks in, and at other times a policy evaluates, and the service does the evaluating.

Round 6 of the validation kit assigned two Win32 apps with a filter on the device name, one in include mode and one in exclude mode. The device the filter matched installed the include app; the other reported Not applicable with “Filters criteria are not met.” The exclude app did the reverse. On the filtered-out device, AppWorkload.log had one line for it:

1
All apps in the subgraph are not applicable due to assignment filters. Skipping processing.

The app left no registry state on that device. Compare that with a base requirement that fails: the Win32 post showed the agent running the detection script for every not-applicable app before it reached the verdict. A filter stops the app before detection, download or install, so a wrong rule leaves you nothing on the device to read. The evidence is a Not applicable in the portal, and the filter evaluation report there can take up to 30 minutes to show it.

A check before the assignment, on a machine you control, finds the mistake while fixing it is still an edit to one line of text.

Asking the service directly

I didn’t want the command built on the reference alone, so I asked the service. Validation/Invoke-FilterProbe.ps1 sends rules to two Graph actions and creates nothing in the tenant:

  • deviceManagement/assignmentFilters/validateFilter, for the Windows 10 and later platform, records whether the service accepts a rule. 114 rules went through it.
  • deviceManagement/evaluateAssignmentFilter, the call behind Preview devices, records which enrolled devices a rule matches. 84 rules went through it, written against the joined test device’s own values so the matrix reads the same in any tenant.

The evaluator also returns the values it compared. For the Entra joined VM it reported cpuArchitecture as amd64, deviceTrustType as Azure AD joined, operatingSystemSKU as EnterpriseSEval and osVersion as 10.0.26100.9457. deviceOwnership came back as Corporate, and enrollmentProfileName and deviceCategory were empty. The registered VM came back Azure AD registered and Personal. One column surprised me: operatingSystemVersion came back as a number, 1.00000000000026E+25, which is 10.0.26100.9457 with each part padded to eight digits. That’s how the service orders versions, and it explains the comparison results later in this post.

The results are the “Assignment filter rules” section of Validation/Findings.md, each row tagged with the experiment IDs (FLT-V25, FLT-E07 and so on) the module cites in its messages.

My own desktop first

With no -Device, the command reads the machine it’s running on. I ran it on my desktop with a rule for ARM devices on 24H2 or later that are Entra joined:

1
2
$rule = '(device.cpuArchitecture -in ["arm64"]) and (device.operatingSystemVersion -ge 10.0.26100) and (device.deviceTrustType -eq "Azure AD joined")'
Test-IntuneAssignmentFilter -Rule $rule
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Applicable : False
Matched    : False
Mode       : Include
Reason     : Not applicable: the rule does not match this device; the portal shows "Filters criteria are not
             met." (W32-FILTER-INCLUDE)
Clauses    : device.cpuArchitecture -in ["arm64"] [not matched, actual: amd64]
             device.operatingSystemVersion -ge 10.0.26100 [matched, actual: 10.0.26220.9606]
             device.deviceTrustType -eq "Azure AD joined" [not matched, actual: Unknown]
Warnings   : {}
Rule       : (device.cpuArchitecture -in ["arm64"]) and (device.operatingSystemVersion -ge 10.0.26100) and
             (device.deviceTrustType -eq "Azure AD joined")

Each clause reports what it compared, so you can see which one failed without splitting the rule apart. Two did here. The desktop is x64, and it isn’t joined to anything, so its trust type is Unknown.

Those values come from Get-IslFilterDeviceFact. It reads them from the places that match what the evaluator returned. The architecture comes from the OS, translated to the filter’s vocabulary:

1
2
3
4
5
6
$architecture = switch ("$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)") {
    'Arm64' { 'arm64' }
    'X64' { 'amd64' }
    'X86' { 'x86' }
    default { 'unknown' }
}

OSArchitecture describes the operating system, so a 32-bit PowerShell on a 64-bit machine still reads amd64. I checked that from the SysWOW64 powershell.exe on the same desktop: Is64BitProcess was False and OSArchitecture was X64. The version is built from the CurrentVersion registry key as major.minor.build.UBR, which is how the evaluator showed it. The join type comes from dsregcmd /status:

1
2
3
4
$trustType = if ($joined -and $domainJoined) { 'Hybrid Azure AD joined' }
elseif ($joined) { 'Azure AD joined' }
elseif ($registered) { 'Azure AD registered' }
else { 'Unknown' }

deviceOwnership is inferred from that: Corporate for a joined device, Personal for a registered one, the way the two lab devices reported it. On my desktop both came out Unknown as I’ve kept it free of the Intune test environment. Every result carries the values it used in its Device property, so (Test-IntuneAssignmentFilter -Rule $rule).Device prints the full set of local facts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
cpuArchitecture        : amd64
deviceCategory         :
deviceName             : BETARPC
deviceOwnership        : Unknown
deviceTrustType        : Unknown
enrollmentProfileName  :
isTpmAttested          :
manufacturer           : LENOVO
model                  : 90T00003US
operatingSystemSKU     : Core
operatingSystemVersion : 10.0.26220.9606
osVersion              : 10.0.26220.9606

operatingSystemSKU is Core because the desktop runs Windows 11 Home. The reference’s SKU table lists Home as “Core (10/111)”, while Windows reports Home as SKU 101 in Win32_OperatingSystem, so the module’s table maps by the number Windows reports and keeps the reference’s name.

The three blank properties live in the tenant. A device doesn’t know its enrollment profile name or its device category, so the local read leaves them empty, and a rule that depends on them needs -Device.

Describing a device

-Device takes a hashtable or an object whose property names are the filter properties. Anything you leave out is treated as empty. This one describes a lab laptop by hand:

1
2
3
4
5
6
7
8
9
$device = @{
    deviceName = 'LAB-042'; manufacturer = 'LENOVO'; model = '21KCS0CB00'
    operatingSystemVersion = '10.0.26100.4652'; osVersion = '10.0.26100.4652'
    operatingSystemSKU = 'Enterprise'; cpuArchitecture = 'amd64'
    deviceTrustType = 'Azure AD joined'; deviceOwnership = 'Corporate'
}
$rule = '(device.manufacturer -eq "lenovo ") and (device.model -contains "21KC") and (device.operatingSystemSKU -in ["Enterprise","Education"])'
(Test-IntuneAssignmentFilter -Rule $rule -Device $device).Clauses |
    Format-Table Property, Operator, Value, Actual, Matched
1
2
3
4
5
Property           Operator Value                   Actual     Matched
--------           -------- -----                   ------     -------
manufacturer       eq       lenovo                  LENOVO        True
model              contains 21KC                    21KCS0CB00    True
operatingSystemSKU in       {Enterprise, Education} Enterprise    True

The manufacturer clause matched with the wrong case and a trailing space. That’s what the evaluator did: every comparison the probe tried was case-insensitive, and leading or trailing spaces in a value were ignored, for -eq, -in and -startsWith alike. -contains is a substring test and -startsWith a prefix test. They aren’t PowerShell’s collection operators, even though the rule language borrows PowerShell’s spelling.

The same -Device is how you check a device you can’t run the command on, or a rule that depends on tenant values. The Device property on the result holds only the keys you passed, so a misspelled key shows up there under its misspelling and the property you meant isn’t listed at all. The clause that reads it shows the miss as an empty actual:.

Rules the service refuses

If the service would refuse your rule, the parser refuses it too, and the message tells you where:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
(device.deviceName -eq 'LAB-042')
  -> Filter rule: the value 'LAB-042' must be in double quotes (position 24)
(device.deviceName -endsWith "042")
  -> Filter rule: -endsWith is refused by the service; only -startsWith and -contains exist (position 20)
not (device.deviceName -eq "LAB-042")
  -> Filter rule: 'not' is not an operator the service accepts; negate with -ne, -notIn or -notContains (at position 1)
(device.deviceName -eq "")
  -> Filter rule: an empty string is refused (position 24); compare with $null for a device without a value
(device.model -eq ["A","B"])
  -> Filter rule: -eq takes a single value; a list needs -in or -notIn (position 19)
(device.operatingSystemVersion -startsWith "10.0")
  -> Filter rule: -startsWith is not allowed on device.operatingSystemVersion (position 32); allowed: -eq, -ne, -gt, -ge, -lt, -le

Single quotes are the one you’ll hit first if you write PowerShell all day, since the rule looks like PowerShell. A double quote inside a value has no escape at all. The service refused both \" and "", and since 0.31.0 the message says so:

1
2
(device.model -eq "a \"quoted\" b")
  -> Filter rule: a double quote inside a value cannot be escaped: the service refuses both \" and "" (the value at position 19); match the parts around the quote with -contains or -startsWith instead

Learn gives the same advice for older iPad Pro models, whose names use a double prime for inches. An apostrophe, a backslash and a tab inside a value were all fine.

The operator names surprised me. Learn says you can use Null or $Null “with the -Equals and -NotEquals operators”, and the filter troubleshooting page writes its examples as device.model -equals "Surface pro". The service refused -equals and -notEquals outright. Those are the reference’s labels for -eq and -ne, and the parser’s table of refused operators names the right one:

1
2
3
4
5
6
7
8
9
$refusedOperators = @{
    'not'           = "'not' is not an operator the service accepts; negate with -ne, -notIn or -notContains"
    '!'             = "'!' is not an operator the service accepts; negate with -ne, -notIn or -notContains"
    'endswith'      = '-endsWith is refused by the service; only -startsWith and -contains exist'
    'notendswith'   = '-notEndsWith is refused by the service; only -startsWith and -notContains exist'
    'notstartswith' = '-notStartsWith is refused by the service; only -startsWith exists'
    'equals'        = '-equals is refused by the service; the operator is -eq'
    'notequals'     = '-notEquals is refused by the service; the operator is -ne'
}

One refusal is stricter than the evaluator. -eq with a list fails validateFilter, but when the probe sent the same rule straight to the evaluator, it read the list as any-of and matched. The parser sides with validateFilter, because that’s the check a rule has to pass before it can be saved.

The rest of what was accepted is looser than the docs suggest. The dash on operators and connectors is optional, and casing doesn’t count anywhere, property names included. Parentheses around a single clause are optional. A bare string after -in works as a one-item list, and a trailing comma inside a list is tolerated. Newlines inside a rule are fine, so a long rule can be laid out one clause per line. This rule mixes the forms, and the parser takes it:

1
DEVICE.DEVICENAME EQ "lab-042" AND device.model -CONTAINS "21kc"

Values no Windows device reports

The refusals are the easy half, because the portal won’t let you save them. The opening example is the hard half: rules the service accepts that can’t match a Windows device.

cpuArchitecture accepts "x64", and an x64 device is amd64. deviceTrustType accepts "Microsoft Entra joined", and the values are still Azure AD joined, Azure AD registered, Hybrid Azure AD joined and Unknown. The rename reached the property’s display label, “Microsoft Entra join type”, and stopped there. "AzureADJoined", the Graph joinType spelling, matched nothing either.

operatingSystemSKU on the LTSC evaluation VM evaluated as EnterpriseSEval, while Graph’s managedDevice.skuFamily for the same device says Enterprise. A rule written from what Graph shows you, -eq "Enterprise", missed that device. deviceOwnership is Corporate or Personal, and Graph’s managedDeviceOwnerType calls the first one company, which matched nothing.

The parser carries the documented value sets in Get-IslFilterProperty and warns when a rule uses something else:

1
WARNING: 'Microsoft Entra joined' is not a value a Windows device reports for device.deviceTrustType (Azure AD joined, Azure AD registered, Hybrid Azure AD joined, Unknown); the clause at character 29 never matches

The direction of the clause changes what the warning means. With -eq or -in, an unknown value fails on every device. With -ne or -notIn it passes on every device, the opposite mistake. The code keeps the two apart:

1
2
3
4
5
6
7
if ($Property.Values -and $Operator -in 'eq', 'in', 'ne', 'notIn') {
    # -eq and -in with such a value match nobody; -ne and -notIn match every device
    $positive = $Operator -in 'eq', 'in'
    foreach ($item in @($value)) {
        if ($null -ne $item -and "$item".Trim() -notin $Property.Values) {
            $warningKind = if ($positive) { 'NeverMatches' } else { 'AlwaysMatches' }
            $effect = if ($positive) { 'never matches' } else { 'matches every device' }

So (device.deviceTrustType -ne "Hybrid Entra joined"), meant to keep hybrid devices out, keeps everything in:

1
'Hybrid Entra joined' is not a value a Windows device reports for device.deviceTrustType (Azure AD joined, Azure AD registered, Hybrid Azure AD joined, Unknown); the clause at character 29 matches every device

Before 0.26.0 the module called both directions “never matches”, and the second one was wrong. The changelog entry for 0.26.0 has the fix.

operatingSystemSKU gets the warning too, from the reference’s SKU table, but only for values outside that table. "Enterprise" is a real SKU name, so the warning can’t catch the LTSC case; a described device with operatingSystemSKU = 'EnterpriseSEval' can.

Which connector binds first

The reference doesn’t say which connector binds tighter. The evaluator showed that and does, the same as in most languages, and parentheses override it. That’s easy to get wrong when a rule grows one clause at a time. Say the intent is “lab machines or Surface Laptop 7s, as long as they’re ARM”, written without the outer parentheses:

1
2
$rule = 'device.deviceName -startsWith "LAB-" or device.model -eq "Surface Laptop 7" and device.cpuArchitecture -eq "arm64"'
Test-IntuneAssignmentFilter -Rule $rule -Device $device
1
2
3
4
5
6
7
Applicable : True
Matched    : True
Mode       : Include
Reason     : Included: the rule matches this device, so the assignment applies
Clauses    : device.deviceName -startsWith "LAB-" [matched, actual: LAB-042]
             device.model -eq "Surface Laptop 7" [not matched, actual: 21KCS0CB00]
             device.cpuArchitecture -eq "arm64" [not matched, actual: amd64]

The x64 lab laptop got in. The rule reads as LAB-* or (Surface Laptop 7 and arm64), so the device name alone was enough. The command evaluates both sides of every and and or even when the first side decides it, so each clause reports what it saw. That’s why you can see the architecture clause failed while the result still matched. Wrapping the two or clauses in parentheses gives the rule its intended meaning.

Two version properties

Windows gives you two OS version properties, and they compare differently.

operatingSystemVersion is a version. The service compares it numerically with missing parts read as 0, which is what the eight-digit padding is for. osVersion is a string, deprecated in the reference, and compared as text. Against the lab laptop at 10.0.26100.4652:

1
2
3
4
5
6
(device.operatingSystemVersion -eq 10.0.26100)     False
(device.operatingSystemVersion -ge 10.0.26100)     True
WARNING: device.osVersion is deprecated in the filter reference; device.operatingSystemVersion compares versions numerically
(device.osVersion -eq "10.0.26100")                False
WARNING: device.osVersion is deprecated in the filter reference; device.operatingSystemVersion compares versions numerically
(device.osVersion -startsWith "10.0.26100")        True

-eq 10.0.26100 reads as 10.0.26100.0, which isn’t 10.0.26100.4652. To mean “anything on build 26100”, use a range:

1
(device.operatingSystemVersion -ge 10.0.26100) and (device.operatingSystemVersion -lt 10.0.26101)

The osVersion -startsWith row gets the same answer as text, on a property the reference says will be removed.

operatingSystemVersion takes 2 to 4 numeric parts, quoted or bare. One part and five parts were refused, and so were -startsWith, -in and $null on it. It’s the only property with -gt, -ge, -lt and -le, and the parser refuses those operators everywhere else, as the service did.

The module’s comparison holds each part as a long, so a part of 100000000 (the service accepted one) doesn’t overflow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function Compare-Version {
    # -1, 0 or 1 the way the filter evaluator ordered versions; $null when a side is not one
    param([string]$Actual, [string]$Expected)
    $left = Get-VersionPart -Text $Actual
    $right = Get-VersionPart -Text $Expected
    if ($null -eq $left -or $null -eq $right) { return $null }
    for ($index = 0; $index -lt 4; $index++) {
        if ($left[$index] -lt $right[$index]) { return -1 }
        if ($left[$index] -gt $right[$index]) { return 1 }
    }
    0
}

Properties the tenant holds

enrollmentProfileName and deviceCategory were empty on both lab devices, so they became the test of how the evaluator treats a missing value. It behaves like an empty string:

1
2
3
4
(device.enrollmentProfileName -eq $null)                True
(device.enrollmentProfileName -ne "Kiosk")              True
(device.enrollmentProfileName -startsWith "Kiosk")      False
(device.deviceCategory -notIn ["Lab","Kiosk"])          True

-eq $null is the way to test for a missing value, since -eq "" is refused. The -ne and -notIn rows show the side effect: a negative clause on an empty property passes. Learn’s troubleshooting page describes the timing problem that follows from it. A device can check in before the user picks a category in Company Portal, so a category filter is evaluated against an empty category first. In exclude mode, a filter meant to keep that category out lets the app through on the first check-in, and the app isn’t removed when the category arrives. The local check can’t see that window, but it can show you that an empty value passes your -ne clause, which is how that install gets through.

One more empty-ish case: " ", a single space, is accepted as a value, and (device.deviceName -contains " ") matched every device in the probe. The module gets the same answer by trimming the value, which leaves an empty substring, and every name contains one. Since 0.31.0 it also warns about it:

1
' ' is only whitespace, which the evaluator trims to nothing, and every value contains that; the clause at character 30 matches every device

isTpmAttested isn’t in the reference, but validateFilter accepted it with -eq and -ne and the evaluator returned False for both VMs. The parser accepts it with a warning that it’s undocumented. Like the other tenant properties, it’s empty in a local read.

Exclude filters

An assignment attaches a filter in include or exclude mode. -Mode Exclude flips the verdict and leaves the match alone:

1
Test-IntuneAssignmentFilter -Rule '(device.operatingSystemVersion -lt 10.0.26100.5000)' -Device $device -Mode Exclude
1
2
3
4
5
Applicable : False
Matched    : True
Mode       : Exclude
Reason     : Not applicable: the exclude rule matches this device (W32-FILTER-EXCLUDE)
Clauses    : device.operatingSystemVersion -lt 10.0.26100.5000 [matched, actual: 10.0.26100.4652]

Matched is the rule’s answer and Applicable is the assignment’s, which is the four-row table on Learn’s troubleshooting page. Keeping both on the result lets you assert on either one in a test.

The warnings mean something different in this mode. An exclude filter with "x64" in it excludes nobody, and a -ne clause that matches every device excludes all of them. Since 0.31.0 an exclude-mode warning ends with what it does to the assignment:

1
2
'x64' is not a value a Windows device reports for device.cpuArchitecture (amd64, x86, arm64, unknown); the clause at character 29 never matches; as an exclude filter it excludes nobody, so the assignment reaches every device in the group
'Hybrid Entra joined' is not a value a Windows device reports for device.deviceTrustType (Azure AD joined, Azure AD registered, Hybrid Azure AD joined, Unknown); the clause at character 29 matches every device; as an exclude filter it excludes every device, so the assignment reaches nobody

Every filter in a tenant

-Rule binds by property name from the pipeline, and a filter object from Graph has a rule property, so you can check a whole tenant’s filters without evaluating anything:

1
2
3
4
5
Get-MgBetaDeviceManagementAssignmentFilter -All |
    Where-Object { "$($_.Platform)" -eq 'windows10AndLater' } |
    Test-IntuneAssignmentFilter -SyntaxOnly -WarningAction SilentlyContinue |
    Where-Object Warnings |
    Format-List Rule, Warnings

-SyntaxOnly parses the rule, collects the warnings and leaves Matched and Applicable empty, so it runs anywhere, including on a Linux CI runner. The platform filter is there for the value tables. An iOS or macOS rule on osVersion, enrollmentProfileName or deviceOwnership parses fine, and only the properties those platforms alone have, like isRooted, are refused. The trouble is the warnings: a correct macOS rule with cpuArchitecture -eq "x64" would get the Windows warning, because the parser checks every value against what a Windows device reports. Fed three sample filter objects shaped like Graph’s, the run picked out the two that needed attention:

1
2
3
4
5
6
7
8
Rule     : (device.cpuArchitecture -eq "x64")
Warnings : {'x64' is not a value a Windows device reports for device.cpuArchitecture (amd64, x86, arm64, unkno
           wn); the clause at character 29 never matches}

Rule     : (device.deviceTrustType -ne "Hybrid Entra joined")
Warnings : {'Hybrid Entra joined' is not a value a Windows device reports for device.deviceTrustType (Azure AD
            joined, Azure AD registered, Hybrid Azure AD joined, Unknown); the clause at character 29 matches
           every device}

Test-IntuneDeployedScript runs the same parser over the filter on every assignment of your remediations, platform scripts and Win32 apps, and reports IslFilterIssue: a Warning for a clause that matches nobody, Information for one that matches everybody or uses syntax the parser can’t read. The tenant-side commands get a post of their own.

Where the local answer can differ

The command evaluates the way the service did for the rules the probe covered. A few gaps remain, and you’ll want them in mind when a local run says a rule matches.

The tenant-held properties are empty in a local read. If a rule uses enrollmentProfileName, deviceCategory or isTpmAttested, a local run treats them as missing, and the real device might have a value. Pass them in -Device when the rule depends on them.

deviceOwnership locally is a guess from the join type. A joined device marked Personal in Intune, or a registered one marked Corporate, reads differently in the tenant. The local Unknown for an unjoined machine is the module’s answer, and Intune won’t have one for a device it hasn’t enrolled.

The manufacturer and model come from Win32_ComputerSystem. The evaluator returned QEMU and Standard PC (Q35 + ICH9, 2009) for the VMs, and I haven’t compared its strings with WMI’s on physical hardware yet.

The SKU table covers the numbers in the reference. A SKU outside it comes back as its number, and no rule’s name will match that.

The property list is the Windows device set, so properties that only other platforms have are refused, and so are managed-app filters with app.* properties. The 3,072-character limit and the 200-filter limit per tenant aren’t checked.

The syntax rows and the matching rows, with the experiment behind each, are the “Assignment filter rules” section of Validation/Findings.md, and the probe that produced them is Validation/Invoke-FilterProbe.ps1. The described-device runs in this post were made with IntuneScriptLab 0.30.0 and the run on my desktop with 0.29.0, whose filter code is the same. The quote, whitespace and exclude-mode messages are from 0.31.0.