# Data Model: Persona Engine **Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](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** - `AccountObjectId` and `UserPrincipalName` MUST be non-empty; a record failing this is an upstream defect and MUST raise, not silently skip. - `Properties` lookups are case-insensitive (RE-006). - A `$null` or absent value in `Properties` is 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 `*Retrieved` flag defaults to `$false`. A condition MUST read the flag for the facet it actually queries, and a `$false` MUST yield `Unknown`, propagating to `EvaluationError` (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 = $true` is 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`** ```text 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](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](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.