function Write-PersonaAuditRecord { <# .SYNOPSIS The single emission point for audit records (FR-022, OTD-006). .DESCRIPTION Serializes one record as newline-delimited JSON and emits it to file, to the object stream, or both, per logging.destination. Every audit record in the engine passes through here. That is the whole design: adding a transport - an approved logging platform, an event hub, a different file layout - is a change to this function and nothing else. If call sites wrote their own output, each new transport would mean auditing every call site again, and the one that got missed would be silent. Emission failure never ends the run. A full disk or a locked file is an operational problem with the audit sink, not a reason to abandon a classification run mid-population and leave the directory in a half-reconciled state. The failure is surfaced as a warning, once, and processing continues. .PARAMETER Record An ordered dictionary from New-PersonaAuditRecord. .PARAMETER Destination file, stream, both, or none. .PARAMETER Path Output file for the file and both destinations. .PARAMETER State Optional sink state carrying the one-warning latch, so a failing sink warns once per run rather than once per user. #> [CmdletBinding()] param( [Parameter(Mandatory, ValueFromPipeline)] [object] $Record, [ValidateSet('file', 'stream', 'both', 'none')] [string] $Destination = 'stream', [string] $Path, [object] $State ) process { if ($Destination -eq 'none') { return } if ($Destination -in @('stream', 'both')) { # The Information stream, not the success stream. Audit records emitted # onto the success stream would be indistinguishable from a function's # return value: the run loop returns its outcome there, and mixing the two # would turn one object into an array of several thousand. # # The record object is emitted, not a string, so a caller capturing it # with -InformationVariable can assert on fields without reparsing. Write-Information -MessageData $Record -Tags 'PersonaEngine.Audit' } if ($Destination -in @('file', 'both')) { if (-not $Path) { Write-Warning 'logging.destination requests file output but no path is configured. No audit file was written.' return } try { $line = $Record | ConvertTo-Json -Depth 16 -Compress $directory = Split-Path -Parent $Path if ($directory -and -not (Test-Path -LiteralPath $directory)) { $null = New-Item -ItemType Directory -Path $directory -Force } # Append, one record per line. UTF-8 without BOM so the file is # machine-readable by any NDJSON consumer. Add-Content -LiteralPath $Path -Value $line -Encoding utf8NoBOM -ErrorAction Stop } catch { if ($null -ne $State -and $State.FileSinkFailed) { return } if ($null -ne $State) { $State.FileSinkFailed = $true } Write-Warning "Audit file sink failed; the run continues without file output: $($_.Exception.Message)" } } } } function New-PersonaAuditSinkState { <# .SYNOPSIS Creates the per-run sink state for Write-PersonaAuditRecord. .DESCRIPTION Holds the latch that keeps a failing file sink from emitting one warning per user. A run over five thousand accounts with a locked log file should warn once, not five thousand times, or the warning that matters is buried in the noise it generates. #> [CmdletBinding()] [OutputType([pscustomobject])] param() [pscustomobject]@{ PSTypeName = 'PersonaEngine.AuditSinkState' FileSinkFailed = $false } }