Files
dave cdc6bb33d3 Implement Stage A: rule engine, validation, audit, and safety gates
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>
2026-08-20 21:48:19 -04:00

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.