Add CompanyName and Department to the results CSV

Add-PersonaRunResult now optionally accepts the normalized UserRecord and
pulls CompanyName/Department from it for the results.csv row, via
TryGetValue rather than the Properties dictionary indexer so a record built
without those keys doesn't throw. Kept off the PersonaDecisionResult/audit
contract on purpose - this is CSV-only, not a widening of what gets logged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 00:25:36 -04:00
parent 5f125c34f2
commit d1b0ac992e
6 changed files with 30 additions and 9 deletions
+1 -1
View File
@@ -57,7 +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. |
| `resultsFileName` | no | `results.csv` | Per-account results CSV, written next to `path`'s directory. Lists `AccountObjectId`, `UserPrincipalName`, `PersonaStatus`, `CompanyName`, `Department` 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). |
+4 -3
View File
@@ -42,9 +42,10 @@ or the warning that matters is buried in the noise it generates.
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.
persona for `Matched` accounts, otherwise `Unclassified` or `EvaluationError`), `CompanyName`, and
`Department` (as retrieved from the directory, blank when absent). 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:
+2 -2
View File
@@ -47,8 +47,8 @@ configuration look identical if zero-match rules are omitted, and that distincti
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.
with one row per account processed so far — Object ID, UPN, assigned persona/status, company name,
and department — for an operator who wants the current population breakdown without parsing NDJSON.
## Exit codes
@@ -61,7 +61,7 @@ Failing any of these yields `Unchanged`, `WouldUpdate`, or `Skipped` — never a
(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.
ID, UPN, the assigned persona or outcome status, company name, and department.
- **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).
+1 -1
View File
@@ -196,7 +196,7 @@ function Invoke-PersonaEngineRun {
Write-UserPersonaResult -Result $result
Add-PersonaRunResult -Counters $counters -Result $result
Add-PersonaRunResult -Counters $counters -Result $result -UserRecord $record
New-PersonaAuditRecord -Context $Context -RecordType 'UserEvent' -Result $result `
-PreviousValue $previousValue -IncludeTrace:$Tracing |
+21 -1
View File
@@ -96,11 +96,18 @@ function Add-PersonaRunResult {
.PARAMETER Result
A PersonaDecisionResult with both Outcome and Action populated.
.PARAMETER UserRecord
The normalized UserRecord the decision was made from. Optional, and used
only to carry CompanyName/Department onto the results CSV row - it is never
written to counters or audit records, so this does not widen what a
PersonaDecisionResult itself carries.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Counters,
[Parameter(Mandatory)] [object] $Result
[Parameter(Mandatory)] [object] $Result,
[object] $UserRecord
)
$Counters.Processed++
@@ -124,11 +131,24 @@ function Add-PersonaRunResult {
'Skipped' { $Counters.Skipped++ }
}
# TryGetValue rather than the indexer: Properties is a case-insensitive
# Dictionary, whose indexer throws on a missing key rather than returning
# $null. CompanyName/Department are always present when UserRecord comes from
# ConvertTo-PersonaUserRecord, but this stays safe for any other caller too.
$companyName = $null
$department = $null
if ($UserRecord) {
$null = $UserRecord.Properties.TryGetValue('CompanyName', [ref]$companyName)
$null = $UserRecord.Properties.TryGetValue('Department', [ref]$department)
}
# 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
CompanyName = [string]$companyName
Department = [string]$department
})
}