Files
personaEngine2/specs/001-persona-engine/data-model.md
T
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

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

  • 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

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.