# 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](ConfigurationReference.md) is the terse field-by-field reference and the finding-code list; [BusinessRules.md](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`. ```json { "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](BusinessRules.md#the-model) 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` ```json { "type": "property", "property": "Department", "operator": "equals", "value": "Finance" } ``` `property` names one of the [supported intrinsic properties](#supported-properties) or an extension property. The engine reads it from the normalized record, never from a raw Graph response. ### `membership` ```json { "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](#direct-vs-transitive-membership) below. ### `role` ```json { "type": "role", "operator": "memberOf", "roleIds": [""] } ``` `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](BusinessRules.md#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>_`. ```json { "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 ```json { "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: ```json { "operator": "all", "conditions": [ /* … */ ] } ``` or ```json { "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 ```json { "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 ```json { "operator": "any", "conditions": [ { "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] }, { "type": "role", "operator": "memberOf", "roleIds": [""] } ] } ``` ### Nested: "in the test group, OR named like a test account while disabled" ```json { "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](BusinessRules.md#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](ConfigurationReference.md#tri-state-evaluation). --- ## Worked recipes Complete, runnable condition trees for situations that come up constantly. Combine, don't copy-paste blindly — read [BusinessRules.md](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): ```json { "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): ```json { "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): ```json { "operator": "any", "conditions": [ { "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] }, { "type": "role", "operator": "memberOf", "roleIds": [""] } ] } ``` **External by company name, but not your own organization** (`isNotNull` first, so a blank `CompanyName` does not fall through as "external"): ```json { "operator": "all", "conditions": [ { "type": "property", "property": "CompanyName", "operator": "isNotNull" }, { "type": "property", "property": "CompanyName", "operator": "notEquals", "value": "" } ] } ``` **Internal by domain suffix instead of company name:** ```json { "operator": "all", "conditions": [ { "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@" } ] } ``` **Exclude disabled accounts from an otherwise broad rule:** ```json { "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): ```json { "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): ```json { "operator": "all", "conditions": [ { "type": "property", "property": "UserType", "operator": "equals", "value": "Member" }, { "type": "property", "property": "Department", "operator": "isNotNull" } ] } ``` The shipped [`config/persona-engine.example.json`](../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`](../Edit-PersonaEngineConfig.ps1)'s menu, which won't let you save a structurally invalid tree. ```bash 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](BusinessRules.md#changing-a-rule-set) for the full validate-preview-deploy sequence. --- ## Validate what you wrote ```bash 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](ConfigurationReference.md#validation-layers). Then run the rule against fixtures before it ever sees a real account: ```bash pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath -ValidateOnly -NonInteractive ``` See [Testing a rule set without a tenant](BusinessRules.md#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`.