20 KiB
Rule authoring guide
A hands-on manual for writing the rules array in persona-engine.json — every condition type,
every operator, worked examples, and the interactive editor.
This document teaches by example. ConfigurationReference.md is the terse field-by-field reference and the finding-code list; BusinessRules.md is about judgement — ordering, priority bands, when to trust a naming convention. Read this one first if you have never written a rule before.
Anatomy of a rule
Every rule has the same shape: identity fields, a target persona, and a condition tree called
match.
{
"id": "RULE-0040-SERVICE",
"name": "Service accounts",
"description": "Non-human accounts identified by naming convention and group membership.",
"enabled": true,
"priority": 40,
"persona": "Service-Account",
"match": {
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
}
id,name,description,enabled,priority,persona,matchare all required.prioritydecides evaluation order — lower runs first — and the first rule whosematchevaluatesTruewins. See BusinessRules.md for why ordering, not exclusion logic, is how you keep one rule from stepping on another.matchis always a condition group (anall/anynode), never a bare condition — even a rule with a single test needs a one-itemconditionsarray inside a group.- Optional metadata —
tags,owner,changeReference,effectiveDate,notes,testCases— is never evaluated.effectiveDatein particular is a label, not a schedule (Principle I: determinism).
Condition types
Every leaf condition has a type. It decides which other fields are required and where the engine
looks for the answer.
type |
Answers | Required fields | Data source |
|---|---|---|---|
property |
What value does this account have? | property, operator, plus value/values depending on operator |
The normalized user record — directory properties and extension properties |
membership |
Is this account in one of these groups? | operator (memberOf/notMemberOf), groupObjectIds |
Group membership, direct or transitive |
role |
Does this account hold one of these directory roles? | operator (memberOf/notMemberOf), roleIds |
Directory role assignments |
property is the default if type is omitted, but write it explicitly — a rule set is read far
more often than it is written, and an implicit type makes every reader re-derive it.
property
{ "type": "property", "property": "Department", "operator": "equals", "value": "Finance" }
property names one of the supported intrinsic properties or an extension
property. The engine reads it from the normalized record, never from a raw Graph response.
membership
{
"type": "membership",
"operator": "memberOf",
"membershipMode": "transitive",
"groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"]
}
Always identify groups by Object ID, never by display name — names are mutable, IDs are not
(RE-009). membershipMode is optional per condition; see Direct vs. transitive
below.
role
{
"type": "role",
"operator": "memberOf",
"roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"]
}
roleIds takes directory role template IDs — the ID that is stable across tenants, not the
tenant-specific role assignment ID. There is no membership mode for roles; a role is held or it is
not.
Operators
Every operator in one place, with what it needs and how it compares.
String comparisons (property only)
All string comparisons are case-insensitive (RE-006) and compare against the property's value
coerced to a string. A missing or null property compares as an empty string (FR-012) — it never
throws and never produces Unknown.
| Operator | Meaning | Example |
|---|---|---|
equals |
Exact match | { "type": "property", "property": "UserType", "operator": "equals", "value": "Guest" } |
notEquals |
Exact non-match | { "type": "property", "property": "CompanyName", "operator": "notEquals", "value": "Contoso" } |
contains |
Substring present | { "type": "property", "property": "JobTitle", "operator": "contains", "value": "intern" } |
notContains |
Substring absent | { "type": "property", "property": "DisplayName", "operator": "notContains", "value": "test" } |
startsWith |
Prefix match | { "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" } |
endsWith |
Suffix match | { "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@vendor.example.com" } |
Pattern match (property only)
| Operator | Meaning | Example |
|---|---|---|
matchesRegex |
.NET regex, matched case-insensitively | { "type": "property", "property": "UserPrincipalName", "operator": "matchesRegex", "value": "^svc-[a-z0-9]+-\\d{3}@" } |
The pattern is compiled at validation time, not evaluation time — an invalid pattern is a
PE-SEM-016 finding that blocks saving, never a surprise mid-run. Prefer startsWith /
contains when they say what you mean; reach for matchesRegex only when the naming convention
genuinely needs a pattern (fixed-width suffixes, alternation, anchoring).
Set membership (property only)
| Operator | Meaning | Example |
|---|---|---|
in |
Value equals one of a list | { "type": "property", "property": "AccountObjectId", "operator": "in", "values": ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] } |
notIn |
Value equals none of a list | { "type": "property", "property": "Department", "operator": "notIn", "values": ["Finance", "Legal"] } |
in/notIn require values (plural, an array) rather than value. Each candidate is compared
case-insensitively, same as equals. Prefer in over a chain of any-grouped equals conditions
— it says "one list" instead of making a reader count equals clauses to notice they are mutually
exclusive alternatives.
Null tests (property only)
| Operator | Meaning | Example |
|---|---|---|
isNull |
Property is absent, null, or empty string |
{ "type": "property", "property": "CompanyName", "operator": "isNull" } |
isNotNull |
Property has a non-empty value | { "type": "property", "property": "Department", "operator": "isNotNull" } |
isNull/isNotNull must not carry value or values — the schema and PE-SEM-009 both reject
it, because a value sitting on a presence check is either a typo or a misunderstanding, and either
way it would silently be ignored if allowed through.
Both $null and "" count as null. A directory clears an attribute to an empty string as often as
it leaves it entirely unset, and a rule author asking "is this unset" means both.
Membership and role (membership / role only)
| Operator | Meaning | Example |
|---|---|---|
memberOf |
Account is in at least one listed group/role | { "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-…"] } |
notMemberOf |
Account is in none of the listed groups/roles | { "type": "membership", "operator": "notMemberOf", "groupObjectIds": ["00000000-…"] } |
Read Unknown is not false before writing notMemberOf.
If the membership lookup fails, the condition evaluates Unknown, not True — a notMemberOf
never becomes satisfied just because the engine could not check. This is deliberate and is the
single most important safety behaviour in the engine.
Reserved
caseSensitive is accepted by the schema on any condition but is not implemented in v1 — every
comparison is case-insensitive regardless of what you set it to. It exists so a future version that
adds case sensitivity does not need a breaking schema change. Do not set it expecting an effect
today.
Supported properties
| Property | Type as compared |
|---|---|
AccountObjectId |
String (GUID) |
UserPrincipalName |
String |
DisplayName |
String |
UserType |
String — typically Member or Guest |
AccountEnabled |
Boolean, compared as the string "True" or "False" |
CompanyName |
String |
JobTitle |
String |
Department |
String |
Plus any directory extension property, addressed by its full name:
extension_<32-hex-app-id>_<name>.
{ "type": "property", "property": "extension_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_costCenter", "operator": "equals", "value": "1042" }
Anything not on this list, and not shaped like an extension property, is a PE-SEM-015 validation
error rather than a silent non-match — an unsupported property is never retrieved, so the condition
would otherwise compare against a value that is permanently absent and quietly never fire.
AccountEnabled is a boolean at the source but is compared as text, so match it with equals and
the literal string "True" or "False" — not isNull/isNotNull, which test for absence and would
never fire on a boolean the directory always populates.
Direct vs. transitive membership
{ "type": "membership", "operator": "memberOf", "membershipMode": "direct", "groupObjectIds": ["…"] }
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["…"] }
direct— the account is a member of the named group itself.transitive— the account is a member through any chain of nested groups.
membershipMode is per-condition (RE-007); mixing both in one rule set — or one rule — is fully
supported. Omit it to fall back to engine.defaultMembershipMode (itself defaulting to direct).
Use transitive for role-holding groups that other groups nest into, which describes most Tier 0
groups. Use direct when membership is explicitly and individually managed, and nesting into the
group would be a mistake you want the engine to ignore.
Each facet — direct groups, transitive groups, directory roles — is retrieved independently and
fails independently. A transitive lookup timing out does not affect a direct condition in the same
rule; it becomes Unknown only for the conditions that actually needed it.
Do not set dataSources.groups.membershipMode globally unless you specifically want to forbid the
other mode. Leaving it unset means "any mode is acceptable, decide per condition"; setting it turns
every condition using the other mode into a PE-SEM-014 warning.
Combining conditions: all and any
Every condition tree, at every level, is a group:
{ "operator": "all", "conditions": [ /* … */ ] }
or
{ "operator": "any", "conditions": [ /* … */ ] }
conditions holds a mix of leaf conditions and nested groups — a nested group is just an entry in
the array that itself has operator and conditions instead of type.
Simple: all conditions must hold
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
Simple: any condition is enough
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] },
{ "type": "role", "operator": "memberOf", "roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"] }
]
}
Nested: "in the test group, OR named like a test account while disabled"
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000c0"] },
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "test-" },
{ "type": "property", "property": "AccountEnabled", "operator": "equals", "value": "False" }
]
}
]
}
Read the nested group as one unit: it is "named like a test account and disabled", offered as one alternative alongside plain group membership. Nesting composes exactly the way parentheses do in any boolean expression.
Keep nesting to three levels or fewer. The hard ceiling is maxConditionDepth (default 5,
configurable up to 10), and exceeding it fails the rule safe — every account it reaches becomes
EvaluationError rather than silently truncating the tree. But depth is also a readability cost
before it is ever a technical one: a rule that seems to need four or five levels is usually two
rules at different priorities instead. See Nesting for why.
How Unknown moves through a tree
Every condition and every group returns one of three results: True, False, or Unknown.
Unknown means the data needed to answer could not be retrieved — not "we don't know the value," but
"the lookup itself failed."
| Group | If it contains… | Result |
|---|---|---|
all |
any False |
False, regardless of any Unknown sibling |
all |
only True, plus at least one Unknown |
Unknown |
any |
any True |
True, regardless of any Unknown sibling |
any |
only False, plus at least one Unknown |
Unknown |
A definite result always wins over an Unknown sibling — an all group with one False condition
cannot match no matter what else is unknown, so there is no reason to degrade that to an error. Only
when nothing definite decided the group does an Unknown anywhere in it propagate.
An Unknown at a rule's root match makes the account EvaluationError: nothing is written, and
the previously stored persona is preserved. This is why notMemberOf guarding a privileged
classification is safe even when a lookup occasionally fails — the account falls into
EvaluationError, never into a false demotion. Full mechanics: ConfigurationReference.md.
Worked recipes
Complete, runnable condition trees for situations that come up constantly. Combine, don't copy-paste blindly — read BusinessRules.md for when each pattern is and isn't appropriate.
Identify a fixed set of accounts by Object ID (break-glass, service owners — anything where a name would be the wrong signal):
{
"operator": "any",
"conditions": [
{ "type": "property", "property": "AccountObjectId", "operator": "in",
"values": ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] }
]
}
Two independent signals for a consequential classification (naming convention alone is a habit, not a control):
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
Privileged group OR the equivalent directory role (a group nested for one identity model, a role assignment for another — either should count):
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] },
{ "type": "role", "operator": "memberOf", "roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"] }
]
}
External by company name, but not your own organization (isNotNull first, so a blank
CompanyName does not fall through as "external"):
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "CompanyName", "operator": "isNotNull" },
{ "type": "property", "property": "CompanyName", "operator": "notEquals", "value": "<ORGANIZATION-NAME>" }
]
}
Internal by domain suffix instead of company name:
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@<PRIMARY-DOMAIN>" }
]
}
Exclude disabled accounts from an otherwise broad rule:
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "Department", "operator": "equals", "value": "Finance" },
{ "type": "property", "property": "AccountEnabled", "operator": "equals", "value": "True" }
]
}
Match against a custom directory extension attribute (e.g. an HR-fed employment type):
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "extension_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_employmentType",
"operator": "in", "values": ["Contractor", "Vendor"] }
]
}
Default / catch-all rule (lowest priority in the rule set, so every more specific rule wins first):
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserType", "operator": "equals", "value": "Member" },
{ "type": "property", "property": "Department", "operator": "isNotNull" }
]
}
The shipped config/persona-engine.example.json is a
complete rule set built from these same patterns end to end, in priority order — read it alongside
this guide.
Building a rule with the interactive editor
Everything above can be hand-written as JSON, or built through
Edit-PersonaEngineConfig.ps1's menu, which won't let you save a
structurally invalid tree.
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json
From the main menu:
| Key | Action |
|---|---|
L |
List rules — ID, name, priority, enabled state |
A |
Add a rule — prompts for identity fields, then walks you through building match node by node |
E |
Edit a rule — change a top-level field, or add/edit/remove a condition or nested group |
T |
Toggle a rule enabled/disabled |
P |
Change a rule's priority |
D |
Delete a rule |
V |
Re-validate the in-memory document |
R |
Run the rule set against synthetic fixtures (-TestDataPath) |
S |
Save — blocked while any Error finding is outstanding |
Q |
Quit |
When building a condition, the editor prompts [P]roperty [M]embership [R]ole for the type, then
lists the operators valid for whatever you chose next — you cannot accidentally pair memberOf with
a property condition, because the editor only offers the combinations the schema allows.
For scripted use, add -NonInteractive -ValidateOnly to validate a file with no menu at all — see
Changing a rule set for the full validate-preview-deploy
sequence.
Validate what you wrote
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
Exit 0 means all four validation layers passed — syntax, schema, semantic, safety. A non-zero exit
prints the findings that blocked it; codes and their meaning are in
ConfigurationReference.md.
Then run the rule against fixtures before it ever sees a real account:
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive
See Testing a rule set without a tenant for what a good fixture directory looks like.
Cheat sheet
| Operator | type |
Needs | Compares |
|---|---|---|---|
equals / notEquals |
property | value |
Case-insensitive exact |
contains / notContains |
property | value |
Case-insensitive substring |
startsWith / endsWith |
property | value |
Case-insensitive prefix/suffix |
matchesRegex |
property | value (a pattern) |
Case-insensitive regex, validated at save time |
in / notIn |
property | values (array) |
Case-insensitive membership in a list |
isNull / isNotNull |
property | (neither value nor values) |
Absent, null, or empty string |
memberOf / notMemberOf |
membership | groupObjectIds, optional membershipMode |
Group membership, direct or transitive |
memberOf / notMemberOf |
role | roleIds |
Directory role assignment |
Group operators: all (every child must hold) and any (at least one child must hold), nestable to
maxConditionDepth.