Files
personaEngine2/docs/RuleAuthoringGuide.md

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, match are all required.
  • priority decides evaluation order — lower runs first — and the first rule whose match evaluates True wins. See BusinessRules.md for why ordering, not exclusion logic, is how you keep one rule from stepping on another.
  • match is always a condition group (an all/any node), never a bare condition — even a rule with a single test needs a one-item conditions array inside a group.
  • Optional metadata — tags, owner, changeReference, effectiveDate, notes, testCases — is never evaluated. effectiveDate in 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.