Private/Model/New-PpaSnapshot.ps1

# New-PpaSnapshot.ps1 - builds the JSON snapshot model (Wave 4 spec 3.2/3.3).
# The snapshot is the versioned, UNREDACTED serialization of normalized collector
# objects + evaluated findings written alongside the HTML report; delta mode diffs
# two of them offline. This file only BUILDS the ordered model; Export-PpaSnapshot
# serializes it (ConvertTo-Json -Depth 16 always) and Import-PpaSnapshot loads it.
#
# ORDERING CONTRACT (golden-file test pins all of this, both engines):
# - top-level properties in schema order: schemaVersion, toolVersion, snapshotId,
# capturedAt, tenantId, profile, sectionsRun, redactionState, denylistVersion,
# environment, collectorOutcomes, objects, findings
# - collectorOutcomes and objects keys: alphabetical
# - environment.modules keys: alphabetical
# - type arrays and findings: stable input order (as collected / as analyzed)
# - every object: _key, _keySource first, then the projection's own properties
# Identity contract: guid/_key are OPAQUE STRINGS end-to-end (spec 3.3 addendum) -
# no GUID parsing or validation here or anywhere downstream.
# ASCII-only source (Windows PowerShell 5.1).

Set-StrictMode -Off

function Get-PpaSnapshotSchema {
    # The reviewed schema manifest: schemaVersion + per-type source/section/declared
    # arrays. Lives next to this file; reviewed like the provenance registry.
    $path = Join-Path $PSScriptRoot 'ppa-snapshot-schema.json'
    return ([System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) | ConvertFrom-Json)
}

function Get-PpaPostureDenylist {
    # The reviewed compare-time denylist. The writer records only its version string
    # in snapshot metadata (information only - the differ always uses the current list).
    $path = Join-Path $PSScriptRoot 'ppa-posture-denylist.json'
    return ([System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) | ConvertFrom-Json)
}

function Get-PpaRawPathValue {
    # Walk a dotted path ('policies.items') into a collector output. '' means root.
    param($Root, [string]$Path)
    if ([string]::IsNullOrEmpty($Path)) { return $Root }
    $cur = $Root
    foreach ($seg in ($Path -split '\.')) {
        if ($null -eq $cur) { return $null }
        $cur = $cur.$seg
    }
    return $cur
}

function New-PpaSnapshotModel {
    [CmdletBinding()]
    param(
        # Hashtable: section id -> collector output (null when the collector crashed).
        [Parameter(Mandatory = $true)] $RawMap,
        # Post-selection section objects (findings source), in report order.
        [Parameter(Mandatory = $true)][AllowEmptyCollection()] $Sections,
        # Run meta; consumed: version (toolVersion), tenantId (may be absent).
        [Parameter(Mandatory = $true)] $Meta,
        [Parameter(Mandatory = $true)][datetime]$CapturedAt,
        [Parameter(Mandatory = $true)][string]$SnapshotId,
        [string]$ProfileName,
        # Sections included in this snapshot, in orchestration order.
        [string[]]$SectionIds = @('Sensitivity_Labels', 'Data_Loss_Prevention', 'Retention', 'Insider_Risk', 'Audit', 'eDiscovery', 'Communication_Compliance', 'DSPM_for_AI'),
        # Injectable for deterministic tests; default captures the live engine.
        $Environment = $null
    )

    $schema    = Get-PpaSnapshotSchema
    $knownIds  = @('Sensitivity_Labels', 'Data_Loss_Prevention', 'Retention', 'Insider_Risk', 'Audit', 'eDiscovery', 'Communication_Compliance', 'DSPM_for_AI')
    $runIds    = @($SectionIds)

    # ---- environment (spec 3.2) ----
    if ($null -eq $Environment) {
        $mods = [ordered]@{}
        foreach ($mName in @('ExchangeOnlineManagement') | Sort-Object) {
            $m = @(Get-Module -Name $mName -ErrorAction SilentlyContinue | Sort-Object Version -Descending)
            if ($m.Count -gt 0) { $mods[$mName] = [string]$m[0].Version }
        }
        $Environment = [ordered]@{
            psEdition = [string]$PSVersionTable.PSEdition
            psVersion = [string]$PSVersionTable.PSVersion
            modules   = $mods
        }
    }

    # ---- collectorOutcomes: every known collector, alphabetical ----
    # Failed = attempted-and-errored (the orchestrator always attempts every
    # collector and hands a $null raw when one crashed); NotRun = never attempted
    # (no RawMap entry at all - reserved for future orchestration paths).
    $outcomes = [ordered]@{}
    foreach ($id in (@($knownIds + $runIds) | Select-Object -Unique | Sort-Object)) {
        if ($runIds -notcontains $id) { $outcomes[$id] = 'Skipped'; continue }
        if (-not $RawMap.ContainsKey($id)) { $outcomes[$id] = 'NotRun'; continue }
        $raw = $RawMap[$id]
        if ($null -eq $raw) { $outcomes[$id] = 'Failed'; continue }
        $o = [string]$raw.outcome
        $outcomes[$id] = $(if ([string]::IsNullOrEmpty($o)) { 'Failed' } else { $o })
    }

    # ---- objects: alphabetical type order; keys stamped per KEY_SOURCES.md ----
    $objects = [ordered]@{}
    foreach ($typeProp in ($schema.types.PSObject.Properties | Sort-Object Name)) {
        $typeName = $typeProp.Name
        $def      = $typeProp.Value
        if ($runIds -notcontains [string]$def.section) { continue }
        $raw = $null
        if ($RawMap.ContainsKey([string]$def.section)) { $raw = $RawMap[[string]$def.section] }

        $stamped = New-Object System.Collections.Generic.List[object]
        # Keys are opaque, case-sensitive strings; count occurrences ordinally.
        $seen = New-Object 'System.Collections.Generic.Dictionary[string,int]' ([System.StringComparer]::Ordinal)

        if ([string]$def.kind -eq 'singleton') {
            $src = Get-PpaRawPathValue -Root $raw -Path ([string]$def.path)
            if ($null -ne $src) {
                $o = [ordered]@{ _key = $typeName; _keySource = 'Name'; name = $typeName }
                $fieldNames = $null
                if ($def.PSObject.Properties.Name -contains 'fields' -and $def.fields) { $fieldNames = @($def.fields | ForEach-Object { [string]$_ }) }
                foreach ($p in $src.PSObject.Properties) {
                    if ($p.Name -in @('items', 'outcome')) { continue }
                    if ($null -ne $fieldNames -and $fieldNames -notcontains $p.Name) { continue }
                    $o[$p.Name] = $p.Value
                }
                $stamped.Add($o)
            }
        }
        else {
            foreach ($item in @((Get-PpaRawPathValue -Root $raw -Path ([string]$def.path)))) {
                if ($null -eq $item) { continue }
                # Keying rule: Guid -> Identity -> Name (no projection carries an
                # Identity property today; the slot is reserved by the spec).
                $key = ''; $keySource = 'Name'
                if ($item.PSObject.Properties.Name -contains 'guid' -and -not [string]::IsNullOrEmpty([string]$item.guid)) {
                    $key = [string]$item.guid; $keySource = 'Guid'
                }
                elseif ($item.PSObject.Properties.Name -contains 'name') {
                    $key = [string]$item.name
                }
                if ($seen.ContainsKey($key)) {
                    $seen[$key] = $seen[$key] + 1
                    $newKey = '{0}#{1}' -f $key, $seen[$key]
                    Write-Warning ("Snapshot: duplicate key '{0}' in type '{1}' - disambiguated as '{2}'." -f $key, $typeName, $newKey)
                    $key = $newKey
                }
                else { $seen[$key] = 1 }

                $o = [ordered]@{ _key = $key; _keySource = $keySource }
                foreach ($p in $item.PSObject.Properties) { $o[$p.Name] = $p.Value }
                $stamped.Add($o)
            }
        }
        $objects[$typeName] = $stamped.ToArray()
    }

    # ---- findings: {checkId,status,severity,section,title}; severity is null in the
    # five-status model (field kept for schema stability / FindingChanged semantics) ----
    $findings = New-Object System.Collections.Generic.List[object]
    foreach ($sec in @($Sections)) {
        foreach ($f in @($sec.findings)) {
            $findings.Add([ordered]@{
                checkId  = [string]$f.id
                status   = [string]$f.status
                severity = $null
                section  = [string]$sec.id
                title    = [string]$f.title
            })
        }
    }

    $tenantId = $null
    if ($Meta.PSObject.Properties.Name -contains 'tenantId' -and -not [string]::IsNullOrEmpty([string]$Meta.tenantId)) {
        $tenantId = [string]$Meta.tenantId
    }

    return [ordered]@{
        schemaVersion     = [ordered]@{ major = [int]$schema.schemaVersion.major; minor = [int]$schema.schemaVersion.minor }
        toolVersion       = [string]$Meta.version
        snapshotId        = [string]$SnapshotId
        capturedAt        = $CapturedAt.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
        tenantId          = $tenantId
        profile           = $(if ([string]::IsNullOrEmpty($ProfileName)) { $null } else { $ProfileName })
        sectionsRun       = $runIds
        redactionState    = 'none'
        denylistVersion   = [string](Get-PpaPostureDenylist).version
        environment       = $Environment
        collectorOutcomes = $outcomes
        objects           = $objects
        findings          = $findings.ToArray()
    }
}