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>
This commit is contained in:
2026-08-20 21:48:19 -04:00
parent c59c85dd55
commit cdc6bb33d3
124 changed files with 16638 additions and 199 deletions
+120
View File
@@ -0,0 +1,120 @@
function New-PersonaRunCounter {
<#
.SYNOPSIS
Creates the run counter set used by summaries and reconciliation (FR-019 - FR-021).
.DESCRIPTION
Holds two independent tallies that must never be conflated:
Outcome buckets Matched, Unclassified, EvaluationError - what the engine
decided. Mutually exclusive, and their sum must equal
Processed (SC-001, FR-021).
Action buckets Unchanged, WouldUpdate, Updated, UpdateFailed, Skipped -
what happened to the directory. Also mutually exclusive,
but they do NOT reconcile against Processed, because a
user can be Matched and Unchanged at the same time.
Reconciliation checks the outcome buckets only. Checking the action buckets
instead would pass on a run that lost users, because Skipped absorbs
anything unexplained.
RuleCounts is seeded from the full rule set, including disabled rules, at
construction. Seeding at construction rather than on first match is what
makes a zero-match rule distinguishable from an absent one - an operator
asking "did RULE-0030 fire?" gets "no, zero matches" rather than silence.
.PARAMETER Rules
The business rule collection, used to seed RuleCounts.
.OUTPUTS
PersonaEngine.RunCounter
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]] $Rules
)
$ruleCounts = [System.Collections.Generic.List[object]]::new()
foreach ($rule in (@($Rules) | Sort-Object -Property @{ Expression = { [int]$_.priority } }, @{ Expression = { [string]$_.id } })) {
$ruleCounts.Add([pscustomobject]@{
RuleId = [string]$rule.id
Name = [string]$rule.name
Priority = [int]$rule.priority
Enabled = [bool]$rule.enabled
Persona = [string]$rule.persona
Matches = 0
})
}
[pscustomobject]@{
PSTypeName = 'PersonaEngine.RunCounter'
Processed = 0
Matched = 0
Unclassified = 0
EvaluationError = 0
Unchanged = 0
WouldUpdate = 0
Updated = 0
UpdateFailed = 0
Skipped = 0
RuleCounts = $ruleCounts
}
}
function Add-PersonaRunResult {
<#
.SYNOPSIS
Records one decision result into the run counters.
.DESCRIPTION
The only function that increments counters. A single entry point is what
makes reconciliation meaningful: if call sites incremented directly, a
missed increment would look identical to a lost user, and the reconciliation
check would be reporting on its own bookkeeping rather than on the run.
Processed increments exactly once per result, before the outcome switch, so
an unrecognized outcome shows up as a reconciliation failure rather than
being quietly dropped.
.PARAMETER Counters
The run counter set.
.PARAMETER Result
A PersonaDecisionResult with both Outcome and Action populated.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Counters,
[Parameter(Mandatory)] [object] $Result
)
$Counters.Processed++
switch ([string]$Result.Outcome) {
'Matched' {
$Counters.Matched++
$entry = $Counters.RuleCounts | Where-Object { $_.RuleId -eq [string]$Result.MatchedRuleId } | Select-Object -First 1
if ($entry) { $entry.Matches++ }
}
'Unclassified' { $Counters.Unclassified++ }
'EvaluationError' { $Counters.EvaluationError++ }
}
switch ([string]$Result.Action) {
'Unchanged' { $Counters.Unchanged++ }
'WouldUpdate' { $Counters.WouldUpdate++ }
'Updated' { $Counters.Updated++ }
'UpdateFailed' { $Counters.UpdateFailed++ }
'Skipped' { $Counters.Skipped++ }
}
}
@@ -0,0 +1,70 @@
function Test-PersonaReconciliation {
<#
.SYNOPSIS
Verifies Processed = Matched + Unclassified + EvaluationError (FR-021, SC-007).
.DESCRIPTION
Run at every summary and once at completion. Returns $true when the outcome
buckets account for every processed user.
A mismatch is not a data condition and is never reported as one. Outcomes are
assigned by the engine, exactly one per user (SC-001), so if the totals do
not add up the engine lost a user or double-counted one. That is a defect in
this codebase, and the caller emits an EngineDefect record and exit code 5
rather than folding the discrepancy into an ordinary counter where it would
be invisible.
Deliberately checks only the outcome buckets. The action buckets - Unchanged,
WouldUpdate, Updated, UpdateFailed, Skipped - also sum to Processed in a
correct run, but Skipped is a catch-all that would absorb a lost user and let
the check pass on a broken run.
.PARAMETER Counters
The run counter set.
.OUTPUTS
System.Boolean
#>
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory)]
[object] $Counters
)
$sum = [int]$Counters.Matched + [int]$Counters.Unclassified + [int]$Counters.EvaluationError
[int]$Counters.Processed -eq $sum
}
function Get-PersonaReconciliationDetail {
<#
.SYNOPSIS
Describes a reconciliation failure precisely enough to debug it.
.DESCRIPTION
Emitted onto the EngineDefect record. Carries the expected total, the actual
total, and the difference, because "reconciliation failed" alone does not
tell a maintainer whether users were lost or double-counted - and the sign of
the difference does.
#>
[CmdletBinding()]
[OutputType([hashtable])]
param(
[Parameter(Mandatory)]
[object] $Counters
)
$sum = [int]$Counters.Matched + [int]$Counters.Unclassified + [int]$Counters.EvaluationError
@{
severity = 'Error'
defect = 'ReconciliationFailure'
processed = [int]$Counters.Processed
outcomeTotal = $sum
difference = [int]$Counters.Processed - $sum
matched = [int]$Counters.Matched
unclassified = [int]$Counters.Unclassified
evaluationError = [int]$Counters.EvaluationError
description = 'Processed does not equal Matched + Unclassified + EvaluationError. Every processed user must land in exactly one outcome bucket (SC-001); a mismatch is an engine defect, not a property of the data.'
}
}
+82
View File
@@ -0,0 +1,82 @@
function Write-PersonaSummary {
<#
.SYNOPSIS
Renders the rule-match table, outcome totals, and reconciliation result
(FR-019, FR-020, FR-021).
.DESCRIPTION
Emitted every summaryInterval users and once at completion.
Every business rule appears, including disabled rules and rules with zero
matches. A rule that never fired and a rule that is not in the configuration
look identical if zero-match rules are omitted, and the difference is exactly
what an operator investigating "why did nobody get classified as Tier0" needs
to see.
Reconciliation is displayed on every summary, not only when it fails. A check
that is only visible when broken gives an operator no reason to believe it
ran at all.
.PARAMETER Counters
The run counter set.
.PARAMETER SummaryType
Interim or Final. Final is emitted regardless of interval, including when
the interval is 0 (FR-020).
.PARAMETER Mode
Preview or Enforce, shown in the header so a screenshot of a summary is
self-describing.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object] $Counters,
[ValidateSet('Interim', 'Final')]
[string] $SummaryType = 'Interim',
[ValidateSet('Preview', 'Enforce')]
[string] $Mode = 'Preview'
)
$reconciled = Test-PersonaReconciliation -Counters $Counters
Write-Host ''
Write-Host ('=' * 100) -ForegroundColor DarkGray
Write-Host ("{0} summary - mode: {1} - processed: {2}" -f $SummaryType, $Mode, $Counters.Processed) -ForegroundColor Cyan
Write-Host ('=' * 100) -ForegroundColor DarkGray
Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f 'Rule ID', 'Name', 'Priority', 'Enabled', 'Matches') -ForegroundColor DarkGray
foreach ($entry in $Counters.RuleCounts) {
# A disabled rule is dimmed rather than hidden: it is part of the
# configuration and its absence from the output would read as a deletion.
$colour = if (-not $entry.Enabled) { 'DarkGray' } elseif ($entry.Matches -gt 0) { 'Green' } else { 'Gray' }
Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f
$entry.RuleId,
($entry.Name.Length -gt 40 ? $entry.Name.Substring(0, 37) + '...' : $entry.Name),
$entry.Priority,
$entry.Enabled,
$entry.Matches) -ForegroundColor $colour
}
Write-Host ''
Write-Host ('Outcomes Matched: {0} Unclassified: {1} EvaluationError: {2}' -f
$Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError)
Write-Host ('Actions Unchanged: {0} WouldUpdate: {1} Updated: {2} UpdateFailed: {3} Skipped: {4}' -f
$Counters.Unchanged, $Counters.WouldUpdate, $Counters.Updated, $Counters.UpdateFailed, $Counters.Skipped)
if ($reconciled) {
Write-Host ('Reconciliation PASS {0} = {1} + {2} + {3}' -f
$Counters.Processed, $Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError) -ForegroundColor Green
}
else {
Write-Host ('Reconciliation FAIL {0} != {1} + {2} + {3} - this is an engine defect (FR-021)' -f
$Counters.Processed, $Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError) -ForegroundColor Red
}
Write-Host ('=' * 100) -ForegroundColor DarkGray
Write-Host ''
}
@@ -0,0 +1,52 @@
function Write-UserPersonaResult {
<#
.SYNOPSIS
Displays one user's result immediately after evaluation (FR-018, SC-012).
.DESCRIPTION
Emitted per user as it is processed, not batched at the end, so an operator
watching a long run sees progress and can stop early if the impact looks
wrong. That per-user visibility is the whole point of a preview run.
Carries UPN and Account Object ID, which are approved for logs. Never emits
tokens, headers, or raw responses (Principle V).
.PARAMETER Result
A PersonaDecisionResult with Action already set by Compare-PersonaValue.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[object] $Result
)
process {
$colour = switch ($Result.Action) {
'Updated' { 'Green' }
'WouldUpdate' { 'Yellow' }
'UpdateFailed' { 'Red' }
'Skipped' { 'Red' }
default { 'Gray' }
}
$detail = switch ($Result.Outcome) {
'Matched' { "$($Result.CalculatedPersona) [$($Result.MatchedRuleId)]" }
'Unclassified' { 'Unclassified' }
'EvaluationError' { "EvaluationError - $($Result.EvaluationErrorReason)" }
}
$change = switch ($Result.Action) {
'Unchanged' { '=' }
'WouldUpdate' { "'$($Result.StoredPersona)' -> '$($Result.CalculatedPersona)'" }
'Updated' { "'$($Result.StoredPersona)' -> '$($Result.CalculatedPersona)'" }
'UpdateFailed' { "write failed; '$($Result.StoredPersona)' retained" }
'Skipped' { "'$($Result.StoredPersona)' retained" }
default { '' }
}
$line = '{0,-14} {1,-45} {2,-40} {3}' -f $Result.Action, $Result.UserPrincipalName, $detail, $change
Write-Host $line -ForegroundColor $colour
Write-Verbose " ObjectId=$($Result.AccountObjectId) RulesEvaluated=$($Result.RulesEvaluated) DurationMs=$($Result.DurationMs)"
}
}