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. | | `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. | | `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. | | `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). |
+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 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 every account processed so far: `AccountObjectId`, `UserPrincipalName`, `PersonaStatus` (the assigned
persona for `Matched` accounts, otherwise `Unclassified` or `EvaluationError`). It is overwritten in persona for `Matched` accounts, otherwise `Unclassified` or `EvaluationError`), `CompanyName`, and
full each time, not appended, so it always reflects the whole run to that point rather than only the `Department` (as retrieved from the directory, blank when absent). It is overwritten in full each
accounts since the last summary. 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` 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: (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. 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) 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 with one row per account processed so far — Object ID, UPN, assigned persona/status, company name,
operator who wants the current population breakdown without parsing NDJSON. and department — for an operator who wants the current population breakdown without parsing NDJSON.
## Exit codes ## Exit codes
@@ -61,7 +61,7 @@ Failing any of these yields `Unchanged`, `WouldUpdate`, or `Skipped` — never a
(FR-020). (FR-020).
- **Every summary, interim and final**: `logging.resultsFileName` (default `results.csv`, written - **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 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` - **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).
+1 -1
View File
@@ -196,7 +196,7 @@ function Invoke-PersonaEngineRun {
Write-UserPersonaResult -Result $result 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 ` New-PersonaAuditRecord -Context $Context -RecordType 'UserEvent' -Result $result `
-PreviousValue $previousValue -IncludeTrace:$Tracing | -PreviousValue $previousValue -IncludeTrace:$Tracing |
+21 -1
View File
@@ -96,11 +96,18 @@ function Add-PersonaRunResult {
.PARAMETER Result .PARAMETER Result
A PersonaDecisionResult with both Outcome and Action populated. 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()] [CmdletBinding()]
param( param(
[Parameter(Mandatory)] [object] $Counters, [Parameter(Mandatory)] [object] $Counters,
[Parameter(Mandatory)] [object] $Result [Parameter(Mandatory)] [object] $Result,
[object] $UserRecord
) )
$Counters.Processed++ $Counters.Processed++
@@ -124,11 +131,24 @@ function Add-PersonaRunResult {
'Skipped' { $Counters.Skipped++ } '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 # Matched carries the assigned persona; Unclassified/EvaluationError carry
# their outcome name, since CalculatedPersona is 'Unclassified' or $null there. # their outcome name, since CalculatedPersona is 'Unclassified' or $null there.
$Counters.Results.Add([pscustomobject]@{ $Counters.Results.Add([pscustomobject]@{
AccountObjectId = [string]$Result.AccountObjectId AccountObjectId = [string]$Result.AccountObjectId
UserPrincipalName = [string]$Result.UserPrincipalName UserPrincipalName = [string]$Result.UserPrincipalName
PersonaStatus = [string]$Result.Outcome -eq 'Matched' ? [string]$Result.CalculatedPersona : [string]$Result.Outcome PersonaStatus = [string]$Result.Outcome -eq 'Matched' ? [string]$Result.CalculatedPersona : [string]$Result.Outcome
CompanyName = [string]$companyName
Department = [string]$department
}) })
} }