Files

7.0 KiB

Architecture

How Persona Engine is put together, and why the boundaries sit where they do.

The one structural rule

The rule engine is pure. It takes a normalized record and a rule set, and returns a decision. It has no knowledge of Microsoft Graph, no authentication, no console, no filesystem, and no clock.

Everything else follows from that. It is constitution Principle IV, and it is the reason the rule engine can be evaluated on a laptop with no tenant, no credentials, and no network.

Layers

Loaded in this order by PersonaEngine.psm1. Order matters only for readability — PowerShell resolves function names at call time — but the dependency direction is real and one-way.

Layer Responsibility May depend on
Normalization Convert raw directory objects into UserRecord and MembershipRecord nothing
Configuration Load, validate, and resolve configuration Normalization
RuleEngine Evaluate conditions, groups, rules; produce a decision Normalization only
Authentication Acquire a Graph connection nothing in this module
DataProviders Retrieve users, membership, roles; retry policy Normalization
Persistence Compare values; build and issue the write DataProviders
Presentation Per-user output, summaries, reconciliation nothing
Engine The run loop that composes all of the above everything
Audit Build and emit structured records Presentation

The arrow never points into RuleEngine. Nothing in RuleEngine may reference anything from Authentication, DataProviders, Persistence, or Presentation.

The normalization boundary

ConvertTo-PersonaUserRecord and ConvertTo-PersonaMembershipRecord are the only places where a raw Graph shape becomes an engine shape. Downstream of them, nothing knows Graph exists.

Two consequences worth stating plainly:

Fixtures are real inputs. A synthetic UserRecord built in a test is indistinguishable to the engine from one built from a live tenant response. That is what makes the offline suite evidence rather than a rehearsal.

Filtering rules live in one place. memberOf returns directory objects of mixed type. Administrative units arriving on that endpoint are discarded during normalization, not by each caller. An administrative unit ID treated as a group ID would never match — which reads as "not a member", a false non-match, the exact outcome FR-013 exists to prevent.

The MembershipRecord shape

Three independently-retrieved facets — direct groups, transitive groups, directory roles — each with its own retrieval flag and failure reason.

This started as a single record with one Mode field, and running the code against the example configuration is what proved it wrong: RE-007 makes membership mode a per-condition choice, so a rule set may legitimately ask for transitive membership in one rule and direct in another. A record carrying only one mode cannot answer both, and seven of nine fixtures came back as EvaluationError. The engine was right; the contract was wrong.

Independent facets also contain failure. If the transitive lookup times out but the direct lookup succeeded, only conditions needing transitive data become Unknown. One collapsed flag would turn one slow endpoint into a tenant-wide outage.

Every *Retrieved flag defaults to $false. An unset flag means unknown, never not a member, so a forgotten flag degrades to EvaluationError rather than silently misclassifying a privileged account.

Tri-state evaluation

Conditions return 'True', 'False', or 'Unknown' — not a boolean. Unknown propagates through condition groups by the table in data-model.md, and an Unknown reaching a rule's root becomes EvaluationError for that account.

An Unknown at priority 30 stops evaluation even though a lower-priority rule might have matched. Continuing would risk assigning a persona from priority 900 when the account may in truth have matched at 30 — a privilege downgrade drawn from data nobody could read. Preserving the stored value is the only safe answer.

The write gate has one origin

Mode is derived from $PSCmdlet.ShouldProcess() and nothing else. There is no -Preview switch, no configuration key that suppresses writes, and no reading of $WhatIfPreference. Two sources of truth for a write gate is the defect class Principle III exists to prevent: the day they disagree, one of them is wrong and the directory finds out first.

Invoke-PersonaEngine.ps1 owns the ShouldProcess call and passes the result down to Invoke-PersonaEngineRun as a scriptblock. The run loop never learns what -WhatIf is, so it cannot disagree with it — and it defaults to a gate that refuses, so a caller that forgets to supply one previews rather than writes.

Why the run loop is a module function

Invoke-PersonaEngine.ps1 is a thin wrapper: parameter binding, module import, ShouldProcess, exit code. The loop itself is Invoke-PersonaEngineRun in src/Engine/.

That split exists because SC-004 requires proof that a -WhatIf run issues zero writes across a full population. A loop that only exists inside an entry script — one that imports a manifest requiring the Graph SDK — cannot be exercised without a tenant, so the claim could not be tested. What ships and what is tested are now the same code.

Single emission point for audit

Every audit record passes through Write-PersonaAuditRecord. Adding a transport is a change to that one function. If call sites wrote their own output, each new transport would mean auditing every call site again, and the one that got missed would be silent.

Records go to the Information stream, not the success stream. Audit records on the success stream would be indistinguishable from a function's return value — the run loop returns its outcome there, and mixing the two turns one object into an array of several thousand.

Failure containment

Failure Scope Outcome
Configuration invalid Run Exit 1, before any connection is attempted
Authentication fails Run Exit 2
Enumeration truncated Run Exit 3 — a partial population is never processed
One membership lookup fails One facet, one user EvaluationError, stored value preserved
Too many EvaluationError Run status only Exit 4; no value was changed
Counters disagree Run Exit 5, EngineDefect record
One write fails One user UpdateFailed, run continues

The dividing line: anything affecting the whole population ends the run; anything affecting one account is contained and reported.