Add elapsed run time, per-account results CSV, and a default config path

Summaries now show elapsed wall-clock time since the run started, and every
summary (interim and final) overwrites a results.csv (Object ID, UPN,
persona/status) next to the audit log, so an operator has a plain export
without parsing NDJSON. ConfigPath also now defaults to
./config/persona-engine.json instead of requiring -ConfigPath every run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 22:10:24 -04:00
parent 8c8fd47f74
commit 5f125c34f2
14 changed files with 154 additions and 13 deletions
+12 -3
View File
@@ -27,7 +27,8 @@
.PARAMETER ConfigPath
Path to the JSON configuration. Validated through all four layers before any
connection is attempted (FR-002).
connection is attempted (FR-002). Defaults to ./config/persona-engine.json,
resolved against the current directory, when omitted.
.PARAMETER UserObjectId
Evaluate a single user instead of enumerating the tenant. The recommended first
@@ -73,9 +74,8 @@
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $ConfigPath,
[string] $ConfigPath = (Join-Path (Get-Location).Path 'config' 'persona-engine.json'),
[guid] $UserObjectId,
@@ -162,6 +162,13 @@ try {
$logPath = $OutputPath ? $OutputPath : ($configuredPath ? $configuredPath : $defaultLogPath)
$auditParams = @{ Destination = $destination; Path = $logPath; State = $sinkState }
# Lives next to the audit log rather than under its own config key for path -
# one directory to point an operator at, not two. The file name alone is
# configurable because "results.csv" may collide with something else already
# written there.
$resultsFileName = $config.Logging.resultsFileName ? [string]$config.Logging.resultsFileName : 'results.csv'
$resultsPath = Join-Path (Split-Path -Parent $logPath) $resultsFileName
# Tracing is enabled by -Debug or by configuration, and requires acknowledgement
# in the configuration either way (VR-003, enforced in validation layer 4).
$traceRequested = $PSBoundParameters.ContainsKey('Debug') -or
@@ -222,6 +229,8 @@ try {
IsEnforcing = $runConfirmed
ShouldProcessGate = $gate
Tracing = $tracing
StartedUtc = $startedUtc
ResultsPath = $resultsPath
}
if ($UserObjectId -and $UserObjectId -ne [guid]::Empty) { $runParams['UserObjectId'] = $UserObjectId.ToString() }
+1
View File
@@ -53,6 +53,7 @@
'Add-PersonaRunResult'
'Test-PersonaReconciliation'
'Get-PersonaReconciliationDetail'
'Export-PersonaResultsCsv'
# Audit
'New-PersonaAuditContext'
'New-PersonaAuditRecord'
+1 -1
View File
@@ -324,7 +324,7 @@ its Tier 0 group, and one run would quietly demote the entire administrative pop
| Parameter | Notes |
| --- | --- |
| `-ConfigPath <string>` | Required. Validated through all four layers before any connection. |
| `-ConfigPath <string>` | Defaults to `./config/persona-engine.json`. Validated through all four layers before any connection. |
| `-WhatIf` | **The approved no-write control.** |
| `-UserObjectId <GUID>` | Single-user execution. |
| `-OutputPath <string>` | Overrides `logging.path`. Both default to `<current-directory>/logs/persona-engine-audit.ndjson`. |
+1
View File
@@ -26,6 +26,7 @@
},
"logging": {
"destination": "both",
"resultsFileName": "results.csv",
"traceConditionValues": false
},
"personas": [
+6
View File
@@ -105,6 +105,12 @@
"type": "string",
"description": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset."
},
"resultsFileName": {
"type": "string",
"minLength": 1,
"default": "results.csv",
"description": "Per-account results CSV (AccountObjectId, UserPrincipalName, persona/status), written alongside the audit log and overwritten on every summary."
},
"traceConditionValues": {
"type": "boolean",
"default": false,
+1
View File
@@ -57,6 +57,7 @@ property-only rules.
| --- | --- | --- | --- |
| `destination` | no | `both` | `file`, `stream`, `both`, or `none`. `stream` writes records to the PowerShell Information stream. |
| `path` | no | `<current-directory>/logs/persona-engine-audit.ndjson` | NDJSON output file. One record per line. |
| `resultsFileName` | no | `results.csv` | Per-account results CSV, written next to `path`'s directory. Lists `AccountObjectId`, `UserPrincipalName`, `PersonaStatus` for every account processed so far. Overwritten on every summary, interim and final. |
| `traceConditionValues` | no | `false` | Writes evaluated attribute values into audit records. |
| `acknowledgeConditionTracing` | no | `false` | **Required whenever `traceConditionValues` is true** (VR-003). |
+13
View File
@@ -38,6 +38,19 @@ warning, **once** per run, and processing continues.
Once, not once per user: a run over five thousand accounts with a locked log file should warn once,
or the warning that matters is buried in the noise it generates.
## Results CSV
Alongside the NDJSON audit log, every summary — interim and final — (re)writes a plain CSV listing
every account processed so far: `AccountObjectId`, `UserPrincipalName`, `PersonaStatus` (the assigned
persona for `Matched` accounts, otherwise `Unclassified` or `EvaluationError`). It is overwritten in
full each time, not appended, so it always reflects the whole run to that point rather than only the
accounts since the last summary.
It is written next to the audit log — same directory as `logging.path` — under `logging.resultsFileName`
(default `results.csv`). Export failure never ends a run, for the same reason a sink failure doesn't:
a locked file or full disk is an operational problem, not a reason to abandon a classification run
mid-population.
## Record types
| Type | When | Carries |
+5 -1
View File
@@ -44,7 +44,11 @@ the header line and `PE-SAF-001`.
A summary appears every `summaryInterval` accounts and once at the end, listing **every** rule
including disabled and zero-match ones. A rule that never fired and a rule that is not in the
configuration look identical if zero-match rules are omitted, and that distinction is usually what
you are looking for.
you are looking for. Its header shows elapsed wall-clock time since the run started.
Each summary also (re)writes `logging.resultsFileName` (default `results.csv`, next to the audit log)
with one row per account processed so far — Object ID, UPN, and assigned persona/status — for an
operator who wants the current population breakdown without parsing NDJSON.
## Exit codes
@@ -7,7 +7,7 @@ The engine entry point. Retrieval, evaluation, reporting, and controlled persist
```powershell
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory)][string] $ConfigPath,
[Parameter()][string] $ConfigPath = './config/persona-engine.json',
[Parameter()][guid] $UserObjectId,
[Parameter()][string] $OutputPath,
[Parameter()][guid] $CorrelationId
@@ -21,7 +21,7 @@ parameters and are **not** declared.
| Parameter | Required | Behaviour |
| --- | --- | --- |
| `-ConfigPath` | Yes | Path to the JSON configuration. Validated through all four layers before any connection is made (FR-002). |
| `-ConfigPath` | No | Path to the JSON configuration. Defaults to `./config/persona-engine.json`, resolved against the current directory, when omitted. Validated through all four layers before any connection is made (FR-002). |
| `-WhatIf` | No | **The approved no-write control.** Reads, evaluation, comparison, console output, summaries, and audit records all behave identically to enforcement; zero write requests are issued (FR-017, SC-004). |
| `-UserObjectId` | No | Single-user execution for validation. Skips enumeration; retrieves one user. |
| `-OutputPath` | No | Overrides the configured audit output path where permitted. |
@@ -56,9 +56,12 @@ Failing any of these yields `Unchanged`, `WouldUpdate`, or `Skipped` — never a
- **Per user, immediately after evaluation** (FR-018, SC-012): one console line carrying UPN,
Account Object ID, outcome, matched rule ID, stored value, calculated value, and action.
- **Every `summaryInterval` users** (FR-019): a table of all business rules with match counts, plus
outcome totals and a reconciliation check.
outcome totals, elapsed wall-clock time since the run started, and a reconciliation check.
- **At completion**: a final summary regardless of interval, including when the interval is `0`
(FR-020).
- **Every summary, interim and final**: `logging.resultsFileName` (default `results.csv`, written
next to the audit log) is overwritten with one row per account processed so far — Account Object
ID, UPN, and the assigned persona or outcome status.
- **Reconciliation** at every summary: `Processed = Matched + Unclassified + EvaluationError`
(FR-021). A mismatch is logged as an engine defect, at `Error` severity.
- **Audit records**: see [audit-record.md](audit-record.md).
@@ -105,6 +105,12 @@
"type": "string",
"description": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset."
},
"resultsFileName": {
"type": "string",
"minLength": 1,
"default": "results.csv",
"description": "Per-account results CSV (AccountObjectId, UserPrincipalName, persona/status), written alongside the audit log and overwritten on every summary."
},
"traceConditionValues": {
"type": "boolean",
"default": false,
+21 -3
View File
@@ -51,6 +51,16 @@ function Invoke-PersonaEngineRun {
.PARAMETER Tracing
Include ConditionTrace on results and audit records.
.PARAMETER StartedUtc
Run start timestamp, threaded down to Write-PersonaSummary so each summary
can show elapsed wall-clock time. Optional; a caller that omits it just gets
summaries without an elapsed figure.
.PARAMETER ResultsPath
Destination for the per-account results CSV. When supplied, it is
(re)written after every summary - interim and final. Optional; a caller
that omits it skips the CSV export entirely.
.OUTPUTS
PersonaEngine.RunOutcome carrying the counters and the exit code.
#>
@@ -74,7 +84,11 @@ function Invoke-PersonaEngineRun {
[string] $UserObjectId,
[switch] $Tracing
[switch] $Tracing,
[Nullable[datetime]] $StartedUtc,
[string] $ResultsPath
)
$EXIT_OK = 0
@@ -190,7 +204,9 @@ function Invoke-PersonaEngineRun {
if ($Configuration.SummaryInterval -gt 0 -and ($counters.Processed % $Configuration.SummaryInterval) -eq 0) {
Write-PersonaSummary -Counters $counters -SummaryType 'Interim' -Mode $Context.Mode
Write-PersonaSummary -Counters $counters -SummaryType 'Interim' -Mode $Context.Mode -StartedUtc $StartedUtc
if ($ResultsPath) { Export-PersonaResultsCsv -Counters $counters -Path $ResultsPath }
New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
-Properties @{ summaryType = 'Interim' } | Write-PersonaAuditRecord @AuditParameters
@@ -206,7 +222,9 @@ function Invoke-PersonaEngineRun {
}
# Always emitted, whatever the interval - including 0 (FR-020).
Write-PersonaSummary -Counters $counters -SummaryType 'Final' -Mode $Context.Mode
Write-PersonaSummary -Counters $counters -SummaryType 'Final' -Mode $Context.Mode -StartedUtc $StartedUtc
if ($ResultsPath) { Export-PersonaResultsCsv -Counters $counters -Path $ResultsPath }
New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
-Properties @{ summaryType = 'Final' } | Write-PersonaAuditRecord @AuditParameters
@@ -0,0 +1,49 @@
function Export-PersonaResultsCsv {
<#
.SYNOPSIS
Writes the per-account results CSV (object ID, UPN, persona/status).
.DESCRIPTION
Called after every summary, interim and final, and overwrites the file each
time. Counters.Results accumulates for the whole run, so the file on disk
always lists every account processed so far, not just those since the last
summary.
Export failure never ends the run, matching Write-PersonaAuditRecord's
failure handling for the same reason: a locked file or a full disk is an
operational problem with the export, not a reason to abandon a
classification run mid-population.
.PARAMETER Counters
The run counter set.
.PARAMETER Path
Destination CSV file. The parent directory is created if it does not exist.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object] $Counters,
[Parameter(Mandatory)]
[string] $Path
)
if ($Counters.Results.Count -eq 0) { return }
try {
$directory = Split-Path -Parent $Path
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
$null = New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false -Confirm:$false
}
# -WhatIf:$false / -Confirm:$false pin this write regardless of any ambient
# $WhatIfPreference in the caller's session, the same reason
# Write-PersonaAuditRecord pins its own sink writes: this is a report, not a
# directory mutation, and must not silently no-op under -WhatIf.
$Counters.Results | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding utf8NoBOM -Force -WhatIf:$false -Confirm:$false
}
catch {
Write-Warning "Results CSV export failed; the run continues without it: $($_.Exception.Message)"
}
}
@@ -24,6 +24,11 @@ function New-PersonaRunCounter {
makes a zero-match rule distinguishable from an absent one - an operator
asking "did RULE-0030 fire?" gets "no, zero matches" rather than silence.
Results accumulates one row per processed account (AccountObjectId,
UserPrincipalName, persona/status) for Export-PersonaResultsCsv. It grows
for the life of the run, not just since the last summary, so the CSV a
summary writes always reflects every account processed so far.
.PARAMETER Rules
The business rule collection, used to seed RuleCounts.
@@ -67,6 +72,7 @@ function New-PersonaRunCounter {
Skipped = 0
RuleCounts = $ruleCounts
Results = [System.Collections.Generic.List[object]]::new()
}
}
@@ -117,4 +123,12 @@ function Add-PersonaRunResult {
'UpdateFailed' { $Counters.UpdateFailed++ }
'Skipped' { $Counters.Skipped++ }
}
# Matched carries the assigned persona; Unclassified/EvaluationError carry
# their outcome name, since CalculatedPersona is 'Unclassified' or $null there.
$Counters.Results.Add([pscustomobject]@{
AccountObjectId = [string]$Result.AccountObjectId
UserPrincipalName = [string]$Result.UserPrincipalName
PersonaStatus = [string]$Result.Outcome -eq 'Matched' ? [string]$Result.CalculatedPersona : [string]$Result.Outcome
})
}
+18 -2
View File
@@ -27,6 +27,11 @@ function Write-PersonaSummary {
.PARAMETER Mode
Preview or Enforce, shown in the header so a screenshot of a summary is
self-describing.
.PARAMETER StartedUtc
Run start timestamp. When supplied, the header shows elapsed wall-clock
time since the run began. Optional so callers that only care about counts
are not forced to thread a clock through.
#>
[CmdletBinding()]
param(
@@ -37,14 +42,25 @@ function Write-PersonaSummary {
[string] $SummaryType = 'Interim',
[ValidateSet('Preview', 'Enforce')]
[string] $Mode = 'Preview'
[string] $Mode = 'Preview',
[Nullable[datetime]] $StartedUtc
)
$reconciled = Test-PersonaReconciliation -Counters $Counters
# $StartedUtc arrives here already unwrapped to a plain [datetime] - PowerShell
# collapses [Nullable[datetime]] to DateTime (or $null) at the call boundary, so
# a null check is used rather than .Value / .HasValue.
$elapsed = ($null -ne $StartedUtc) ? ('{0:hh\:mm\:ss}' -f ([DateTime]::UtcNow - $StartedUtc)) : $null
Write-Host ''
Write-Host ('=' * 100) -ForegroundColor DarkGray
Write-Host ("{0} summary - mode: {1} - processed: {2}" -f $SummaryType, $Mode, $Counters.Processed) -ForegroundColor Cyan
Write-Host (
$elapsed `
? ("{0} summary - mode: {1} - elapsed: {2} - processed: {3}" -f $SummaryType, $Mode, $elapsed, $Counters.Processed) `
: ("{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