From 5f125c34f2e073e669d87bc90c9cfca5c4651dc0 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 24 Aug 2026 22:10:24 -0400 Subject: [PATCH] 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 --- Invoke-PersonaEngine.ps1 | 15 ++++-- PersonaEngine.psd1 | 1 + README.md | 2 +- config/persona-engine.example.json | 1 + config/persona-engine.schema.json | 6 +++ docs/ConfigurationReference.md | 1 + docs/Logging.md | 13 +++++ docs/OperationsRunbook.md | 6 ++- .../contracts/cli-invoke-persona-engine.md | 9 ++-- .../contracts/persona-engine.schema.json | 6 +++ src/Engine/Invoke-PersonaEngineRun.ps1 | 24 +++++++-- src/Presentation/Export-PersonaResultsCsv.ps1 | 49 +++++++++++++++++++ src/Presentation/New-PersonaRunCounter.ps1 | 14 ++++++ src/Presentation/Write-PersonaSummary.ps1 | 20 +++++++- 14 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 src/Presentation/Export-PersonaResultsCsv.ps1 diff --git a/Invoke-PersonaEngine.ps1 b/Invoke-PersonaEngine.ps1 index a53cdd7..69deb2f 100644 --- a/Invoke-PersonaEngine.ps1 +++ b/Invoke-PersonaEngine.ps1 @@ -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() } diff --git a/PersonaEngine.psd1 b/PersonaEngine.psd1 index 0e98178..30024f5 100644 --- a/PersonaEngine.psd1 +++ b/PersonaEngine.psd1 @@ -53,6 +53,7 @@ 'Add-PersonaRunResult' 'Test-PersonaReconciliation' 'Get-PersonaReconciliationDetail' + 'Export-PersonaResultsCsv' # Audit 'New-PersonaAuditContext' 'New-PersonaAuditRecord' diff --git a/README.md b/README.md index 0e84a75..44496ec 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ its Tier 0 group, and one run would quietly demote the entire administrative pop | Parameter | Notes | | --- | --- | -| `-ConfigPath ` | Required. Validated through all four layers before any connection. | +| `-ConfigPath ` | Defaults to `./config/persona-engine.json`. Validated through all four layers before any connection. | | `-WhatIf` | **The approved no-write control.** | | `-UserObjectId ` | Single-user execution. | | `-OutputPath ` | Overrides `logging.path`. Both default to `/logs/persona-engine-audit.ndjson`. | diff --git a/config/persona-engine.example.json b/config/persona-engine.example.json index 8aa78ba..24ee0f1 100644 --- a/config/persona-engine.example.json +++ b/config/persona-engine.example.json @@ -26,6 +26,7 @@ }, "logging": { "destination": "both", + "resultsFileName": "results.csv", "traceConditionValues": false }, "personas": [ diff --git a/config/persona-engine.schema.json b/config/persona-engine.schema.json index 3bfe3a6..8cf6f82 100644 --- a/config/persona-engine.schema.json +++ b/config/persona-engine.schema.json @@ -105,6 +105,12 @@ "type": "string", "description": "NDJSON output file for 'file'/'both' destinations. Defaults to /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, diff --git a/docs/ConfigurationReference.md b/docs/ConfigurationReference.md index a4aadf2..88bb3b6 100644 --- a/docs/ConfigurationReference.md +++ b/docs/ConfigurationReference.md @@ -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 | `/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). | diff --git a/docs/Logging.md b/docs/Logging.md index 53f5d1e..7204e0f 100644 --- a/docs/Logging.md +++ b/docs/Logging.md @@ -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 | diff --git a/docs/OperationsRunbook.md b/docs/OperationsRunbook.md index 9ee043c..887eeea 100644 --- a/docs/OperationsRunbook.md +++ b/docs/OperationsRunbook.md @@ -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 diff --git a/specs/001-persona-engine/contracts/cli-invoke-persona-engine.md b/specs/001-persona-engine/contracts/cli-invoke-persona-engine.md index 9284630..8d48042 100644 --- a/specs/001-persona-engine/contracts/cli-invoke-persona-engine.md +++ b/specs/001-persona-engine/contracts/cli-invoke-persona-engine.md @@ -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). diff --git a/specs/001-persona-engine/contracts/persona-engine.schema.json b/specs/001-persona-engine/contracts/persona-engine.schema.json index 3bfe3a6..8cf6f82 100644 --- a/specs/001-persona-engine/contracts/persona-engine.schema.json +++ b/specs/001-persona-engine/contracts/persona-engine.schema.json @@ -105,6 +105,12 @@ "type": "string", "description": "NDJSON output file for 'file'/'both' destinations. Defaults to /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, diff --git a/src/Engine/Invoke-PersonaEngineRun.ps1 b/src/Engine/Invoke-PersonaEngineRun.ps1 index 5030240..b5bd51a 100644 --- a/src/Engine/Invoke-PersonaEngineRun.ps1 +++ b/src/Engine/Invoke-PersonaEngineRun.ps1 @@ -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 diff --git a/src/Presentation/Export-PersonaResultsCsv.ps1 b/src/Presentation/Export-PersonaResultsCsv.ps1 new file mode 100644 index 0000000..32f441c --- /dev/null +++ b/src/Presentation/Export-PersonaResultsCsv.ps1 @@ -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)" + } +} diff --git a/src/Presentation/New-PersonaRunCounter.ps1 b/src/Presentation/New-PersonaRunCounter.ps1 index c710692..229bc15 100644 --- a/src/Presentation/New-PersonaRunCounter.ps1 +++ b/src/Presentation/New-PersonaRunCounter.ps1 @@ -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 + }) } diff --git a/src/Presentation/Write-PersonaSummary.ps1 b/src/Presentation/Write-PersonaSummary.ps1 index df0118b..b34ebd5 100644 --- a/src/Presentation/Write-PersonaSummary.ps1 +++ b/src/Presentation/Write-PersonaSummary.ps1 @@ -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