Completes 109 of 121 tasks. Every remaining task needs a tenant connection
(T055, T056, T101-T103) or an Azure Automation account (T115-T121).
354 offline Pester tests PASS
Engine purity (Principle IV) PASS
Sanitization (SC-013) PASS (156 files)
Graph module loaded in tests none (SC-008 holds)
What landed
- Four-layer configuration validation with stable finding codes, covering
every VR-002 and VR-003 condition, plus a 23-fixture invalid-config corpus
- Run loop, audit records (NDJSON through a single sink), summaries,
reconciliation, and exit codes 0-6
- Persistence behind a single write-body builder whose result always has
exactly one key
- Invoke-PersonaEngine.ps1 and Edit-PersonaEngineConfig.ps1
- Six docs, two pipelines, traceability matrix, V-5a and sanitization records
Three deviations from tasks.md, each recorded in its status block
T033 is not in Resolve-UserPersona. evaluationErrorThreshold is run-level
state and the rule engine is pure; a counter there would break Principle IV.
It lives in New-PersonaRunCounter and is applied in the run loop.
A new src/Engine/ layer holds Invoke-PersonaEngineRun. The entry script
imports the manifest, which requires Microsoft.Graph.Authentication, so a
loop living only inside it could not run on a machine without the Graph SDK
and SC-004 could not be proven at all. The entry script is now a thin
wrapper and what ships is what is tested.
The invalid-config corpus is generated by a committed script, with the
generated fixtures committed too, so a reviewer sees the fixture in the diff.
Defects found by running the code, not by reading it
Group and role ID lists were double-wrapped: @(Get-PersonaGroupIdPage ...)
around a comma-returned array collapsed every membership list into one
bogus space-joined entry. That is a silent false non-match, exactly what
FR-013 exists to prevent.
A 403 whose status appears only in the exception message parsed as $null,
which the retry policy treats as a transport error - five requests per
account against a tenant already refusing. Status extraction now falls back
to the message text, bounded to 400-599.
The sanitization scan walked tracked files only, so it covered 34 of 156
files and none of this phase's code. It now scans untracked non-ignored
files too, and a negative control confirms it catches a planted leak.
Test-Json reports one error per violating location, not first-failure-only
as the V-5a draft claimed. Record and pin corrected.
Enforcement remains blocked on the V-4 security sign-off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
Configuration reference
Every field in persona-engine.json, and every finding code the validator can produce.
The authoritative schema is config/persona-engine.schema.json
(JSON Schema draft-07). A working example is
config/persona-engine.example.json, which is validated by
CI against its own schema — if the example the documentation points at could not pass, every reader's
first run would fail.
Top level
| Field | Required | Notes |
|---|---|---|
configVersion |
yes | Semantic version, major.minor.patch. A downgrade is a safety finding. |
metadata |
no | owner, changeReference, description. Free-form; not read by the engine. |
engine |
yes | Engine behaviour. |
dataSources |
yes | Which directory data may be retrieved. |
logging |
no | Audit output. |
personas |
yes | The declared persona catalogue. |
rules |
yes | Ordered business rules. |
engine
| Field | Required | Default | Notes |
|---|---|---|---|
targetAttribute |
yes | — | The single attribute the engine may write. Must be a directory extension property and must appear in approvedWritableAttributes. |
approvedWritableAttributes |
yes | — | The allow-list. Comparison is ordinal — extension property names are case-sensitive in Graph. |
maxConditionDepth |
no | 5 |
RE-004. Minimum 1, hard ceiling 10. |
summaryInterval |
no | 25 |
Interim summary frequency. 0 suppresses interim summaries; a final summary always appears. |
defaultMembershipMode |
no | direct |
Mode for membership conditions that do not specify one. |
evaluationErrorThreshold |
no | unset | Count of EvaluationError results above which the run reports exit code 4. Unset means report, do not fail. |
Setting evaluationErrorThreshold to 0 makes a single transient lookup failure fail the run. That
is occasionally what you want; it is rarely what you meant.
dataSources
| Field | Required | Notes |
|---|---|---|
groups.enabled |
yes | When false, no membership facet is retrieved. Rules needing it become EvaluationError. |
groups.membershipMode |
no | Pins a mode globally. Leave it out unless you mean to restrict — absent means "any mode is acceptable", and RE-007 makes mode a per-condition choice. Pinning it makes every per-condition override a PE-SEM-014 warning. |
roles.enabled |
yes | Directory role assignments. |
roles.includeEligible |
no | PIM-eligible assignments. Out of scope for v1; no provider is implemented. |
The engine retrieves only the facets enabled rules actually reference. A configuration with no role conditions never calls the role endpoint, so a tenant where role reads are unavailable can still run property-only rules.
logging
| Field | Required | Default | Notes |
|---|---|---|---|
destination |
no | both |
file, stream, both, or none. stream writes records to the PowerShell Information stream. |
path |
no | — | NDJSON output file. One record per line. |
traceConditionValues |
no | false |
Writes evaluated attribute values into audit records. |
acknowledgeConditionTracing |
no | false |
Required whenever traceConditionValues is true (VR-003). |
personas
The declared catalogue. A rule assigning a persona absent from this list is a PE-SEM-010 error — the
catalogue is what stops a typo from writing a new persona value into the directory.
Unclassified and EvaluationError are processing results and may never be declared or assigned.
rules
| Field | Required | Notes |
|---|---|---|
id |
yes | Unique. Appears in every audit record; this is how a decision is traced to its rule. |
name |
yes | Human-readable. |
description |
yes | Why the rule exists. Required, because a rule nobody can explain cannot be safely changed. |
enabled |
yes | Disabled rules are excluded from evaluation but still appear in summaries with zero matches. |
priority |
yes | Unique integer. Lower evaluates first. |
persona |
yes | Must appear in personas. |
match |
yes | The root condition group. |
tags, owner, changeReference, effectiveDate, notes, testCases |
no | Metadata. effectiveDate is not evaluated — a date-dependent decision would break determinism. |
Priorities must be unique among enabled rules. The engine breaks ties by rule ID so results stay
deterministic, but the resulting order is an accident rather than a decision, so PE-SEM-002 blocks it.
Condition groups and conditions
A group has operator (all or any) and a conditions array. Each entry is either another group
or a condition.
| Field | Applies to | Notes |
|---|---|---|
type |
all | property, membership, or role. |
property |
property |
One of the supported names below, or an extension property. |
operator |
all | See the operator table. |
value |
most | Single comparison value. |
values |
in, notIn |
Comparison set. |
groupObjectIds |
membership |
Group Object IDs. Names are mutable; IDs are not (RE-009). |
roleIds |
role |
Role template IDs, which are stable across tenants. |
membershipMode |
membership |
direct or transitive, per condition. |
caseSensitive |
— | Reserved. Not implemented in v1; the schema accepts the key so a later version needs no breaking change. |
Supported properties
AccountObjectId · UserPrincipalName · DisplayName · UserType · AccountEnabled ·
CompanyName · JobTitle · Department
Plus any directory extension property named extension_<32-hex-app-id>_<name>. Anything else is
PE-SEM-015: unsupported properties are never retrieved, so the condition would compare against a
permanently absent value and quietly never match.
Operators (RE-005)
| Operator | Applies to | Notes |
|---|---|---|
equals, notEquals |
property | Case-insensitive (RE-006). |
contains, notContains |
property | Case-insensitive substring. |
startsWith, endsWith |
property | Case-insensitive. |
matchesRegex |
property | Pattern compiled at validation time. An invalid pattern is PE-SEM-016, not a runtime failure. |
in, notIn |
property | Requires values. |
isNull, isNotNull |
property | Tests presence. Must not carry a value — it would be silently ignored (PE-SEM-009). |
memberOf, notMemberOf |
membership, role | Requires groupObjectIds or roleIds. |
Null and absent properties are treated as empty for ordinary comparisons and never cause an
evaluation failure (FR-012). Intentional null matching uses isNull / isNotNull.
Tri-state evaluation
Conditions return True, False, or Unknown. Unknown means required data could not be
retrieved, and it propagates:
| Group | Contains | Result |
|---|---|---|
all |
any False |
False |
all |
only True plus at least one Unknown |
Unknown |
any |
any True |
True |
any |
only False plus at least one Unknown |
Unknown |
An Unknown at a rule's root makes the account EvaluationError: the stored persona is preserved
and no write is attempted (FR-013, FR-014).
Validation layers
Run in order, stopping at the first that produces Error findings. Running semantic checks over a
structurally invalid document yields noise, not signal.
| Layer | Mechanism | Codes |
|---|---|---|
| 1 Syntax | ConvertFrom-Json |
PE-SYN-nnn |
| 2 Schema | Test-Json -SchemaFile |
PE-SCH-nnn |
| 3 Semantic | PowerShell checks | PE-SEM-nnn |
| 4 Safety | PowerShell checks | PE-SAF-nnn |
Codes are stable. Pipelines and runbooks match on them, so a code is never reused for a different condition and never renumbered.
Syntax — PE-SYN
| Code | Condition |
|---|---|
PE-SYN-001 |
Configuration file not found, or is not a file |
PE-SYN-002 |
File exists but could not be read |
PE-SYN-003 |
File is not valid JSON |
Schema — PE-SCH
| Code | Condition |
|---|---|
PE-SCH-001 |
Document violates the schema |
PE-SCH-002 |
Schema file not found |
PE-SCH-003 |
Schema file exists but is not valid JSON Schema |
PE-SCH-003 exists because of V-5a: Test-Json returns $true when the schema itself cannot be
parsed. A wrapper trusting the return value would report every configuration as schema-valid against
a schema that never ran.
Semantic — PE-SEM (VR-002)
| Code | Condition | Severity |
|---|---|---|
PE-SEM-001 |
Duplicate rule ID | Error |
PE-SEM-002 |
Duplicate priority among enabled rules | Error |
PE-SEM-003 |
No rules, or no enabled rules | Error |
PE-SEM-004 |
Blank target attribute | Error |
PE-SEM-005 |
Target attribute absent from the approved list | Error |
PE-SEM-006 |
Rule references a disabled data source | Error |
PE-SEM-007 |
memberOf / notMemberOf with no group or role IDs |
Error |
PE-SEM-008 |
in / notIn with no values |
Error |
PE-SEM-009 |
isNull / isNotNull carrying a comparison value |
Error |
PE-SEM-010 |
Persona not in the declared catalogue | Error |
PE-SEM-011 |
Unclassified used as a rule persona |
Error |
PE-SEM-012 |
Nesting deeper than maxConditionDepth |
Error |
PE-SEM-013 |
maxConditionDepth outside 1–10 |
Error |
PE-SEM-014 |
Condition mode differs from an explicitly pinned global mode | Warning |
PE-SEM-015 |
Unsupported property name | Error |
PE-SEM-016 |
Invalid regular expression | Error |
Several of these are also enforced by the schema. The overlap is deliberate: layer 2 can be bypassed
with -SchemaPath, and V-5a showed an unparseable schema passes silently. Anything that can
misclassify a privileged account is checked twice.
Safety — PE-SAF (VR-003)
| Code | Condition | Severity |
|---|---|---|
PE-SAF-001 |
Blank target attribute | Error enforcing, Warning in preview |
PE-SAF-002 |
Approved list contains a non-extension attribute | Error |
PE-SAF-003 |
Enabled rules need data the data sources do not provide | Error |
PE-SAF-004 |
configVersion lower than the deployed version |
Error enforcing, Warning in preview |
PE-SAF-005 |
Rules removed or reordered with no version change | Error enforcing, Warning in preview |
PE-SAF-006 |
Tracing enabled without acknowledgement | Error |
PE-SAF-007 |
Save would overwrite an existing configuration with no backup | Error |
PE-SAF-004 and PE-SAF-005 need -PreviousConfigPath. Without it they are skipped, and an
Information finding says so — silence would be read as approval.
Escalation (VR-005)
Error blocks execution and saving. Warning blocks only under -TreatWarningsAsErrors.
Information never blocks. Passing -TreatWarningsAsErrors does not change a finding's severity;
it changes the caller's tolerance for it.