229 lines
11 KiB
Markdown
229 lines
11 KiB
Markdown
|
|
# 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.
|