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 .PARAMETER ConfigPath
Path to the JSON configuration. Validated through all four layers before any 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 .PARAMETER UserObjectId
Evaluate a single user instead of enumerating the tenant. The recommended first Evaluate a single user instead of enumerating the tenant. The recommended first
@@ -73,9 +74,8 @@
#> #>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param( param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()] [ValidateNotNullOrEmpty()]
[string] $ConfigPath, [string] $ConfigPath = (Join-Path (Get-Location).Path 'config' 'persona-engine.json'),
[guid] $UserObjectId, [guid] $UserObjectId,
@@ -162,6 +162,13 @@ try {
$logPath = $OutputPath ? $OutputPath : ($configuredPath ? $configuredPath : $defaultLogPath) $logPath = $OutputPath ? $OutputPath : ($configuredPath ? $configuredPath : $defaultLogPath)
$auditParams = @{ Destination = $destination; Path = $logPath; State = $sinkState } $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 # Tracing is enabled by -Debug or by configuration, and requires acknowledgement
# in the configuration either way (VR-003, enforced in validation layer 4). # in the configuration either way (VR-003, enforced in validation layer 4).
$traceRequested = $PSBoundParameters.ContainsKey('Debug') -or $traceRequested = $PSBoundParameters.ContainsKey('Debug') -or
@@ -222,6 +229,8 @@ try {
IsEnforcing = $runConfirmed IsEnforcing = $runConfirmed
ShouldProcessGate = $gate ShouldProcessGate = $gate
Tracing = $tracing Tracing = $tracing
StartedUtc = $startedUtc
ResultsPath = $resultsPath
} }
if ($UserObjectId -and $UserObjectId -ne [guid]::Empty) { $runParams['UserObjectId'] = $UserObjectId.ToString() } if ($UserObjectId -and $UserObjectId -ne [guid]::Empty) { $runParams['UserObjectId'] = $UserObjectId.ToString() }
+1
View File
@@ -53,6 +53,7 @@
'Add-PersonaRunResult' 'Add-PersonaRunResult'
'Test-PersonaReconciliation' 'Test-PersonaReconciliation'
'Get-PersonaReconciliationDetail' 'Get-PersonaReconciliationDetail'
'Export-PersonaResultsCsv'
# Audit # Audit
'New-PersonaAuditContext' 'New-PersonaAuditContext'
'New-PersonaAuditRecord' '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 | | 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.** | | `-WhatIf` | **The approved no-write control.** |
| `-UserObjectId <GUID>` | Single-user execution. | | `-UserObjectId <GUID>` | Single-user execution. |
| `-OutputPath <string>` | Overrides `logging.path`. Both default to `<current-directory>/logs/persona-engine-audit.ndjson`. | | `-OutputPath <string>` | Overrides `logging.path`. Both default to `<current-directory>/logs/persona-engine-audit.ndjson`. |
+1
View File
@@ -26,6 +26,7 @@
}, },
"logging": { "logging": {
"destination": "both", "destination": "both",
"resultsFileName": "results.csv",
"traceConditionValues": false "traceConditionValues": false
}, },
"personas": [ "personas": [
+6
View File
@@ -105,6 +105,12 @@
"type": "string", "type": "string",
"description": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset." "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": { "traceConditionValues": {
"type": "boolean", "type": "boolean",
"default": false, "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. | | `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. | | `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. | | `traceConditionValues` | no | `false` | Writes evaluated attribute values into audit records. |
| `acknowledgeConditionTracing` | no | `false` | **Required whenever `traceConditionValues` is true** (VR-003). | | `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, 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. 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 ## Record types
| Type | When | Carries | | 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 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 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 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 ## Exit codes
@@ -7,7 +7,7 @@ The engine entry point. Retrieval, evaluation, reporting, and controlled persist
```powershell ```powershell
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param( param(
[Parameter(Mandatory)][string] $ConfigPath, [Parameter()][string] $ConfigPath = './config/persona-engine.json',
[Parameter()][guid] $UserObjectId, [Parameter()][guid] $UserObjectId,
[Parameter()][string] $OutputPath, [Parameter()][string] $OutputPath,
[Parameter()][guid] $CorrelationId [Parameter()][guid] $CorrelationId
@@ -21,7 +21,7 @@ parameters and are **not** declared.
| Parameter | Required | Behaviour | | 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). | | `-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. | | `-UserObjectId` | No | Single-user execution for validation. Skips enumeration; retrieves one user. |
| `-OutputPath` | No | Overrides the configured audit output path where permitted. | | `-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, - **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. 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 - **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` - **At completion**: a final summary regardless of interval, including when the interval is `0`
(FR-020). (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` - **Reconciliation** at every summary: `Processed = Matched + Unclassified + EvaluationError`
(FR-021). A mismatch is logged as an engine defect, at `Error` severity. (FR-021). A mismatch is logged as an engine defect, at `Error` severity.
- **Audit records**: see [audit-record.md](audit-record.md). - **Audit records**: see [audit-record.md](audit-record.md).
@@ -105,6 +105,12 @@
"type": "string", "type": "string",
"description": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset." "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": { "traceConditionValues": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,
+21 -3
View File
@@ -51,6 +51,16 @@ function Invoke-PersonaEngineRun {
.PARAMETER Tracing .PARAMETER Tracing
Include ConditionTrace on results and audit records. 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 .OUTPUTS
PersonaEngine.RunOutcome carrying the counters and the exit code. PersonaEngine.RunOutcome carrying the counters and the exit code.
#> #>
@@ -74,7 +84,11 @@ function Invoke-PersonaEngineRun {
[string] $UserObjectId, [string] $UserObjectId,
[switch] $Tracing [switch] $Tracing,
[Nullable[datetime]] $StartedUtc,
[string] $ResultsPath
) )
$EXIT_OK = 0 $EXIT_OK = 0
@@ -190,7 +204,9 @@ function Invoke-PersonaEngineRun {
if ($Configuration.SummaryInterval -gt 0 -and ($counters.Processed % $Configuration.SummaryInterval) -eq 0) { 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 ` New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
-Properties @{ summaryType = 'Interim' } | Write-PersonaAuditRecord @AuditParameters -Properties @{ summaryType = 'Interim' } | Write-PersonaAuditRecord @AuditParameters
@@ -206,7 +222,9 @@ function Invoke-PersonaEngineRun {
} }
# Always emitted, whatever the interval - including 0 (FR-020). # 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 ` New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
-Properties @{ summaryType = 'Final' } | Write-PersonaAuditRecord @AuditParameters -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 makes a zero-match rule distinguishable from an absent one - an operator
asking "did RULE-0030 fire?" gets "no, zero matches" rather than silence. 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 .PARAMETER Rules
The business rule collection, used to seed RuleCounts. The business rule collection, used to seed RuleCounts.
@@ -67,6 +72,7 @@ function New-PersonaRunCounter {
Skipped = 0 Skipped = 0
RuleCounts = $ruleCounts RuleCounts = $ruleCounts
Results = [System.Collections.Generic.List[object]]::new()
} }
} }
@@ -117,4 +123,12 @@ function Add-PersonaRunResult {
'UpdateFailed' { $Counters.UpdateFailed++ } 'UpdateFailed' { $Counters.UpdateFailed++ }
'Skipped' { $Counters.Skipped++ } '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 .PARAMETER Mode
Preview or Enforce, shown in the header so a screenshot of a summary is Preview or Enforce, shown in the header so a screenshot of a summary is
self-describing. 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()] [CmdletBinding()]
param( param(
@@ -37,14 +42,25 @@ function Write-PersonaSummary {
[string] $SummaryType = 'Interim', [string] $SummaryType = 'Interim',
[ValidateSet('Preview', 'Enforce')] [ValidateSet('Preview', 'Enforce')]
[string] $Mode = 'Preview' [string] $Mode = 'Preview',
[Nullable[datetime]] $StartedUtc
) )
$reconciled = Test-PersonaReconciliation -Counters $Counters $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 ''
Write-Host ('=' * 100) -ForegroundColor DarkGray 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 ('=' * 100) -ForegroundColor DarkGray
Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f 'Rule ID', 'Name', 'Priority', 'Enabled', 'Matches') -ForegroundColor DarkGray Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f 'Rule ID', 'Name', 'Priority', 'Enabled', 'Matches') -ForegroundColor DarkGray