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
Data Model: Persona Engine
Date: 2026-08-20 | Spec: spec.md | Plan: plan.md
Normalized in-memory contracts. These are the objects the rule engine sees. Per Principle IV the rule engine MUST NOT receive raw directory responses — normalization is the boundary.
All types are plain PSCustomObject shapes. Field types are PowerShell types.
UserRecord
Produced by ConvertTo-PersonaUserRecord. Consumed by the rule engine, presentation, and audit.
| Field | Type | Required | Notes |
|---|---|---|---|
AccountObjectId |
string (GUID) |
Yes | Immutable identity key. Approved for logs. |
UserPrincipalName |
string |
Yes | Approved for logs. |
DisplayName |
string |
No | Diagnostics only. |
UserType |
string |
No | Member / Guest. |
AccountEnabled |
bool |
Yes | Disabled accounts remain in scope (FR-011). |
Properties |
hashtable |
Yes | Case-insensitive map of evaluable property name → value. Populated from FR-005 selection plus any property a rule references. Absent property returns $null. |
StoredPersona |
string |
No | Current value of the target attribute; $null when unset. |
Membership |
MembershipRecord |
Yes | Never $null; an unattempted lookup is represented by an empty record with RetrievalSucceeded = $true and Mode = 'None'. |
Validation rules
AccountObjectIdandUserPrincipalNameMUST be non-empty; a record failing this is an upstream defect and MUST raise, not silently skip.Propertieslookups are case-insensitive (RE-006).- A
$nullor absent value inPropertiesis treated as empty for ordinary string comparisons and MUST NOT fail evaluation (FR-012).
MembershipRecord
Produced by ConvertTo-PersonaMembershipRecord. This type carries the most safety-critical fields
in the model.
Revised 2026-08-20 during implementation. The original design held a single Mode field
(Direct / Transitive / None) alongside one GroupObjectIds set. That cannot satisfy RE-007,
which makes membership mode a per-condition choice: a rule set may legitimately ask for
transitive membership in one rule and direct membership in another, and a single-mode record can
only answer one of them — every user became an EvaluationError on the other. The defect was
caught by running the shipped example configuration, which mixes both modes, against the fixtures.
The record now holds three independently-retrieved facets.
| Field | Type | Required | Notes |
|---|---|---|---|
DirectGroupObjectIds |
string[] |
Yes | May be empty. |
DirectRetrieved |
bool |
Yes | $false means "unknown", never "not a member". |
DirectFailureReason |
string |
No | Populated only when DirectRetrieved is $false. |
TransitiveGroupObjectIds |
string[] |
Yes | May be empty. |
TransitiveRetrieved |
bool |
Yes | Same semantics as DirectRetrieved. |
TransitiveFailureReason |
string |
No | |
DirectoryRoleIds |
string[] |
Yes | May be empty. |
RolesRetrieved |
bool |
Yes | Same semantics. |
RolesFailureReason |
string |
No |
Validation rules
- Every
*Retrievedflag defaults to$false. A condition MUST read the flag for the facet it actually queries, and a$falseMUST yieldUnknown, propagating toEvaluationError(FR-013). - Facets are independent: a failed transitive lookup MUST NOT make direct-membership conditions unevaluable. Collapsing them would turn one slow endpoint into a tenant-wide outage.
- An empty identifier collection with its facet
Retrieved = $trueis a legitimate "member of nothing" and evaluates normally. - A facet MUST NOT be both retrieved and carry a failure reason; the constructor throws.
- Mode selection is exact. A condition asking for transitive membership MUST NOT be answered from direct data (false negatives on nested groups) and vice versa (false positives).
BusinessRule
Deserialized from configuration. Never constructed in source (Principle II).
| Field | Type | Required | Notes |
|---|---|---|---|
Id |
string |
Yes | Unique across the rule set (VR-002). |
Name |
string |
Yes | |
Description |
string |
Yes | |
Enabled |
bool |
Yes | Disabled rules are skipped and excluded from the enabled count. |
Priority |
int |
Yes | Unique; lower evaluates first (RE-002). |
Persona |
string |
Yes | MUST be a defined persona; MUST NOT be Unclassified or EvaluationError (VR-002). |
Match |
ConditionGroup |
Yes | Root condition group. |
Tags |
string[] |
No | |
Owner |
string |
No | |
ChangeReference |
string |
No | |
EffectiveDate |
string |
No | Metadata only in v1 — MUST NOT gate evaluation, as a date-dependent decision would break Principle I. |
Notes |
string |
No | |
TestCases |
object[] |
No | Consumed by the editor's synthetic testing (FR-025). |
ConditionGroup / Condition
Recursive structure bounded by the configured depth (RE-004: default 5, min 1, ceiling 10).
ConditionGroup
| Field | Type | Required | Notes |
|---|---|---|---|
Operator |
string |
Yes | all or any. |
Conditions |
(Condition|ConditionGroup)[] |
Yes | MUST be non-empty. |
Condition (leaf)
| Field | Type | Required | Notes |
|---|---|---|---|
Type |
string |
Yes | property, membership, or role. |
Property |
string |
For property |
MUST be a supported property name (VR-002). |
Operator |
string |
Yes | One of RE-005. |
Value |
string |
Conditional | Required for comparison operators; MUST be absent for isNull / isNotNull (VR-002). |
Values |
string[] |
Conditional | Required for in / notIn. |
GroupObjectIds |
string[] |
For membership |
MUST be non-empty (VR-002). |
RoleIds |
string[] |
For role |
MUST be non-empty. |
MembershipMode |
string |
No | direct or transitive; defaults to the engine setting (RE-007). |
Evaluation result values: every condition evaluates to True, False, or Unknown.
Unknown is what makes FR-013 expressible.
Propagation rules (these are the whole safety argument — implement exactly):
| Group | Contains Unknown |
Result |
|---|---|---|
all |
plus any False |
False — a definite non-match wins; the unknown cannot rescue it |
all |
plus only True |
Unknown |
any |
plus any True |
True — a definite match wins |
any |
plus only False |
Unknown |
An Unknown at the rule root yields EvaluationError for that user.
PersonaDecisionResult
Produced by Resolve-UserPersona. The engine's authoritative per-user output.
| Field | Type | Required | Notes |
|---|---|---|---|
AccountObjectId |
string |
Yes | |
UserPrincipalName |
string |
Yes | |
Outcome |
string |
Yes | Matched, Unclassified, or EvaluationError — exactly one (SC-001). |
MatchedRuleId |
string |
When Matched |
$null otherwise. |
CalculatedPersona |
string |
Yes | The persona, Unclassified, or $null when EvaluationError. |
StoredPersona |
string |
No | Copied from the UserRecord. |
Action |
string |
Yes | Unchanged, WouldUpdate, Updated, UpdateFailed, or Skipped. |
EvaluationErrorReason |
string |
When EvaluationError |
|
RulesEvaluated |
int |
Yes | Count until first match or exhaustion. |
DurationMs |
int |
Yes | Per-user timing (NFR-002). |
ConditionTrace |
object[] |
No | Populated only under -Debug (Principle V). |
State transitions for Action
EvaluationError ─────────────────────────────► Skipped (FR-014, no write ever)
Calculated == Stored ────────────────────────► Unchanged
Calculated != Stored, preview mode ──────────► WouldUpdate (FR-017, no request issued)
Calculated != Stored, enforce, write ok ─────► Updated
Calculated != Stored, enforce, write fails ──► UpdateFailed
Unclassified follows the same comparison path as any other calculated value — it is a legitimate
value to write if the configuration approves it, and is reported distinctly either way.
Configuration
The complete ordered decision process. Structure is normative in contracts/persona-engine.schema.json.
| Field | Type | Required | Notes |
|---|---|---|---|
configVersion |
string |
Yes | Downgrade is a safety violation (VR-003). |
engine.targetAttribute |
string |
Yes | MUST appear in approvedWritableAttributes. |
engine.approvedWritableAttributes |
string[] |
Yes | MUST be non-empty. |
engine.maxConditionDepth |
int |
No | Default 5, min 1, max 10 (RE-004). |
engine.summaryInterval |
int |
No | Default 25; 0 suppresses interim summaries (FR-020). |
engine.defaultMembershipMode |
string |
No | direct or transitive. |
dataSources.groups.enabled |
bool |
Yes | Enabled group rules with this false is a safety violation (VR-003). |
dataSources.roles.enabled |
bool |
Yes | |
personas |
string[] |
Yes | Defined persona catalogue. EvaluationError MUST NOT appear. |
logging.* |
object |
No | Destination and path (OTD-006). |
rules |
BusinessRule[] |
Yes | MUST contain at least one enabled rule (VR-002). |
Derived at load: ConfigurationHash (SHA-256 of the canonical file bytes) — recorded on every
run record (NFR-005).
ValidationFinding
Produced by all four validation layers (VR-004).
| Field | Type | Required | Notes |
|---|---|---|---|
Severity |
string |
Yes | Error, Warning, Information. |
Code |
string |
Yes | Stable finding code, e.g. PE-SEM-012. |
Location |
string |
Yes | JSON path or rule ID. |
Description |
string |
Yes | |
SuggestedResolution |
string |
Yes | |
Layer |
string |
Yes | Syntax, Schema, Semantic, Safety. |
Error blocks execution and saving; Warning blocks only under -TreatWarningsAsErrors (VR-005).
RunRecord
One per execution. See contracts/audit-record.md for the serialized form.
| Field | Type | Required | Notes |
|---|---|---|---|
RunId |
string (GUID) |
Yes | Supplied via -CorrelationId or generated. |
StartedUtc / CompletedUtc |
datetime |
Yes | |
Mode |
string |
Yes | Preview or Enforce. |
EngineVersion |
string |
Yes | |
ConfigVersion / ConfigurationHash |
string |
Yes | |
Processed / Matched / Unclassified / EvaluationError |
int |
Yes | Reconciliation: Processed = Matched + Unclassified + EvaluationError (FR-021). |
Unchanged / WouldUpdate / Updated / UpdateFailed |
int |
Yes | |
ExitCode |
int |
Yes | Per the CLI contract. |
A failed reconciliation MUST be logged as an engine defect, not merely reported.