Public/Invoke-PurviewPostureAnalyzer.ps1

# Invoke-PurviewPostureAnalyzer.ps1 - the entry point. Orchestrates the pipeline:
# run context -> collect (read-only) -> analyze -> assemble -> render HTML + export JSON.
# Graceful degradation: each collector/analyzer is wrapped so one failure yields a
# Verify-manually placeholder section and the run continues (PLAN.md section 2 & 9).
# ASCII-only source (Windows PowerShell 5.1).

Set-StrictMode -Off

function Invoke-PurviewPostureAnalyzer {
    [CmdletBinding()]
    param(
        [string]$OutputDirectory,
        [string]$Organization,
        [datetime]$AsOf = (Get-Date),
        # Run profile (P5): -IncludeSection means "only these"; -ExcludeSection removes.
        # Keys are the top-level section ids (e.g. Audit, DSPM_for_AI). -Profile points
        # to a .psd1/.json file with IncludeSection/ExcludeSection keys expressing the
        # same; explicit parameters override the file. (This parameter intentionally
        # shadows the automatic $Profile variable inside this function only.)
        [string[]]$IncludeSection,
        [string[]]$ExcludeSection,
        [string]$Profile,
        # Redaction (P6): render-time masking of tenant domains/UPNs (-Redact) and,
        # additionally, policy/label name pseudonymization (-RedactNames, implies
        # -Redact). The JSON export and in-memory findings are never modified.
        [switch]$Redact,
        [switch]$RedactNames,
        # Snapshot (Wave 4): a versioned UNREDACTED JSON snapshot is written alongside
        # the HTML by default; -NoSnapshot suppresses it. -IncludeRawCapture also
        # writes the raw collector outputs to a separate debug file outside the schema.
        [switch]$NoSnapshot,
        [switch]$IncludeRawCapture,
        # Delta mode (Wave 4 Part C): fully offline comparison of two snapshots -
        # file-in, HTML-out, no tenant session. PS 7+ only (engine gate inside).
        [string]$DeltaFrom,
        [string]$DeltaTo,
        [string]$OutputPath,
        [switch]$AllowTenantMismatch,
        # Client logo (UX-2): a .png/.jpg/.jpeg embedded into the HTML header as a
        # data: URI (the report stays self-contained/offline). Validated and encoded
        # BEFORE any collection; report chrome only - never the normalized object,
        # the JSON export, or snapshots. Ignored (with a warning) in delta mode.
        [string]$LogoPath,
        # One-go run (UX-1): OPT-IN switches whose defaults change NOTHING (F-007 -
        # PPA never opens or tears down a session unless explicitly told to).
        # -Connect opens the two read sessions only when none are live; -Disconnect
        # closes them in a finally, even on a failed run; -Show opens the finished
        # HTML report; -UserPrincipalName feeds the -Connect sign-in (ignored, with
        # a warning, without -Connect). All four are ignored in delta mode.
        [switch]$Connect,
        [switch]$Disconnect,
        [switch]$Show,
        [string]$UserPrincipalName,
        # Cross-tenant guest / B2B (pre-publish Part 6, Option B): forwarded to the
        # -Connect sign-in so a consultant guest account can read a CLIENT tenant.
        # -DelegatedOrganization names the client tenant (client.onmicrosoft.com);
        # -AzureADAuthorizationEndpointUri is an optional override (auto-derived from
        # the organization otherwise). Both follow the -UserPrincipalName rules:
        # ignored (with a warning) without -Connect, and ignored in delta mode.
        [string]$DelegatedOrganization,
        [string]$AzureADAuthorizationEndpointUri
    )

    # ---- DELTA MODE: short-circuits the whole collection pipeline (spec 4.1) ----
    if (-not [string]::IsNullOrWhiteSpace($DeltaFrom) -or -not [string]::IsNullOrWhiteSpace($DeltaTo)) {
        if ([string]::IsNullOrWhiteSpace($DeltaFrom) -or [string]::IsNullOrWhiteSpace($DeltaTo)) {
            throw 'Delta mode requires BOTH -DeltaFrom and -DeltaTo snapshot paths.'
        }
        if (-not [string]::IsNullOrWhiteSpace($LogoPath)) {
            Write-Warning 'Delta mode ignores -LogoPath (the delta renderer carries no logo slot).'
        }
        # UX-1 switches are tenant-run concepts; delta mode is fully offline (one
        # consolidated warning, same rule as -LogoPath above).
        $ignoredSwitches = @()
        if ($Connect) { $ignoredSwitches += '-Connect' }
        if ($Disconnect) { $ignoredSwitches += '-Disconnect' }
        if ($Show) { $ignoredSwitches += '-Show' }
        if (-not [string]::IsNullOrWhiteSpace($UserPrincipalName)) { $ignoredSwitches += '-UserPrincipalName' }
        if (-not [string]::IsNullOrWhiteSpace($DelegatedOrganization)) { $ignoredSwitches += '-DelegatedOrganization' }
        if (-not [string]::IsNullOrWhiteSpace($AzureADAuthorizationEndpointUri)) { $ignoredSwitches += '-AzureADAuthorizationEndpointUri' }
        if ($ignoredSwitches.Count -gt 0) {
            Write-Warning ("Delta mode ignores {0} - it is a fully offline snapshot comparison (no tenant session, no report auto-open)." -f ($ignoredSwitches -join ', '))
        }
        return Invoke-PpaDelta -FromPath $DeltaFrom -ToPath $DeltaTo -OutputPath $OutputPath -Redact:$Redact -RedactNames:$RedactNames -AllowTenantMismatch:$AllowTenantMismatch
    }

    # ---- CLIENT LOGO (UX-2): validate + encode BEFORE any collection (fail fast - a bad
    # logo path must never cost the operator a full tenant read). The data URI is handed
    # only to the HTML renderer below; it never enters the normalized object, the JSON
    # export, or snapshots. ----
    $logoDataUri = ''
    if (-not [string]::IsNullOrWhiteSpace($LogoPath)) {
        $logoDataUri = ConvertTo-PpaLogoDataUri -Path $LogoPath
    }

    # Resolve the output directory to an ABSOLUTE path against the caller's PowerShell location.
    # A relative path handed to .NET file APIs (WriteAllText) would otherwise resolve against the
    # process current directory - often the user home - not the PowerShell location, which is why
    # a relative -OutputDirectory could land somewhere unexpected.
    if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { $OutputDirectory = 'Outputs' }
    if (-not [System.IO.Path]::IsPathRooted($OutputDirectory)) {
        $OutputDirectory = Join-Path -Path (Get-Location).Path -ChildPath $OutputDirectory
    }
    $OutputDirectory = [System.IO.Path]::GetFullPath($OutputDirectory)

    # ---- RUN PROFILE (P5): resolve and validate BEFORE any collection (fail fast) ----
    $knownSectionIds = @('Sensitivity_Labels', 'Data_Loss_Prevention', 'Retention', 'Insider_Risk', 'Audit', 'eDiscovery', 'Communication_Compliance', 'DSPM_for_AI')
    if (-not [string]::IsNullOrWhiteSpace($Profile)) {
        $profilePath = $Profile
        if (-not [System.IO.Path]::IsPathRooted($profilePath)) { $profilePath = Join-Path -Path (Get-Location).Path -ChildPath $profilePath }
        if (-not (Test-Path -LiteralPath $profilePath)) { throw "Run profile file not found: '$profilePath'." }
        $profileData = switch ([System.IO.Path]::GetExtension($profilePath).ToLower()) {
            '.psd1' { Import-PowerShellDataFile -LiteralPath $profilePath }
            '.json' { [System.IO.File]::ReadAllText($profilePath, [System.Text.Encoding]::UTF8) | ConvertFrom-Json }
            default { throw "Run profile '$profilePath' must be a .psd1 or .json file." }
        }
        # Explicit parameters override the profile file. (Filter before counting: an
        # unbound [string[]] parameter wraps to @($null), whose Count is 1, not 0.)
        if (@($IncludeSection | Where-Object { $_ }).Count -eq 0 -and $profileData.IncludeSection) { $IncludeSection = @($profileData.IncludeSection) }
        if (@($ExcludeSection | Where-Object { $_ }).Count -eq 0 -and $profileData.ExcludeSection) { $ExcludeSection = @($profileData.ExcludeSection) }
    }
    $badKeys = @(@($IncludeSection) + @($ExcludeSection) | Where-Object { $_ -and ($knownSectionIds -notcontains $_) })
    if ($badKeys.Count -gt 0) {
        throw ("Unknown section key(s): {0}. Valid keys: {1}" -f (($badKeys | Select-Object -Unique) -join ', '), ($knownSectionIds -join ', '))
    }

    # ---- RUN MANIFEST (F-008): start recording every cmdlet the read-only chokepoint
    # dispatches this run - metadata only (name, status, count, timestamp), never arguments
    # or content. Started before the -Connect session probe (UX-1) and the run-context +
    # session-diagnostics probes so they are captured too; written alongside the report
    # below. Emitted by default (self-auditability is the point) and never leaves the
    # machine. ----
    Initialize-PpaRunManifest

    # -UserPrincipalName and the guest (B2B) parameters only feed the -Connect
    # sign-in; alone they do nothing (UX-1 rule - one consolidated warning).
    if (-not $Connect) {
        $connectOnly = @()
        if (-not [string]::IsNullOrWhiteSpace($UserPrincipalName)) { $connectOnly += '-UserPrincipalName' }
        if (-not [string]::IsNullOrWhiteSpace($DelegatedOrganization)) { $connectOnly += '-DelegatedOrganization' }
        if (-not [string]::IsNullOrWhiteSpace($AzureADAuthorizationEndpointUri)) { $connectOnly += '-AzureADAuthorizationEndpointUri' }
        if ($connectOnly.Count -gt 0) {
            $verb = if ($connectOnly.Count -gt 1) { 'are' } else { 'is' }
            Write-Warning (($connectOnly -join ', ') + ' ' + $verb + ' only used with -Connect; ignoring.')
        }
    }

    # ---- ONE-GO RUN (UX-1): the whole tenant-run body sits in try/finally so an explicit
    # -Disconnect is honored even when the run throws (including the -Connect both-failed
    # error below). The finally does NOTHING unless -Disconnect was passed - session
    # teardown stays opt-in (F-007). ----
    try {

        # ---- SESSION SETUP (-Connect, opt-in): probe the live sessions first and never
        # disturb one that already exists. The probe is a Get-verb read and goes through
        # the read-only chokepoint like every other read; Connect-PurviewPostureSession
        # below is session management (not a tenant read) and is called DIRECTLY - never
        # through Invoke-PpaReadCmdlet. ----
        if ($Connect) {
            $connProbe = Invoke-PpaReadCmdlet -Name 'Get-ConnectionInformation'
            $liveRows = @()
            if ($connProbe.Status -eq 'Ok') {
                $liveRows = @($connProbe.Data | Where-Object { [string]$_.State -eq 'Connected' })
            }
            # Connected rows split by service: a compliance.* ConnectionUri is the
            # Security & Compliance session; any other Connected row is Exchange Online.
            $sccLive = @($liveRows | Where-Object { [string]$_.ConnectionUri -like '*compliance*' })
            $exoLive = @($liveRows | Where-Object { [string]$_.ConnectionUri -notlike '*compliance*' })
            if ($sccLive.Count -gt 0 -and $exoLive.Count -gt 0) {
                Write-Verbose '-Connect: Security & Compliance and Exchange Online sessions are already live; nothing to connect.'
            }
            elseif ($sccLive.Count -gt 0 -or $exoLive.Count -gt 0) {
                $haveName = if ($sccLive.Count -gt 0) { 'Security & Compliance' } else { 'Exchange Online' }
                $missName = if ($sccLive.Count -gt 0) { 'Exchange Online' } else { 'Security & Compliance' }
                Write-Warning ("-Connect: found a live {0} session but no {1} session - skipping connect rather than disturbing the existing session. If {1}-backed sections come back degraded, run Connect-PurviewPostureSession yourself and re-run." -f $haveName, $missName)
            }
            else {
                $connectArgs = @{}
                if (-not [string]::IsNullOrWhiteSpace($UserPrincipalName)) { $connectArgs['UserPrincipalName'] = $UserPrincipalName }
                # Guest (B2B) forwarding (Part 6): whichever guest parameters were
                # supplied ride along; Connect-PurviewPostureSession owns the
                # endpoint derivation and the endpoint-without-org hygiene warning.
                if (-not [string]::IsNullOrWhiteSpace($DelegatedOrganization)) { $connectArgs['DelegatedOrganization'] = $DelegatedOrganization }
                if (-not [string]::IsNullOrWhiteSpace($AzureADAuthorizationEndpointUri)) { $connectArgs['AzureADAuthorizationEndpointUri'] = $AzureADAuthorizationEndpointUri }
                $connStatus = Connect-PurviewPostureSession @connectArgs
                $sccOk = ([string]$connStatus.SecurityCompliance -eq 'connected')
                $exoOk = ([string]$connStatus.ExchangeOnline -eq 'connected')
                if (-not $sccOk -and -not $exoOk) {
                    # Both services failed: stop BEFORE any collection - a run with zero
                    # readable services produces an all-degraded report nobody wants.
                    # (Guard-scan note: the gallery-install hint below is composed at
                    # runtime so no mutating-verb cmdlet literal appears in source - it
                    # is message text for the operator, never an invocation.)
                    $hint = ''
                    if (([string]$connStatus.SecurityCompliance + ' ' + [string]$connStatus.ExchangeOnline) -match 'module not installed') {
                        $hint = (' Install it first: ' + 'Install' + '-Module ExchangeOnlineManagement -Scope CurrentUser.')
                    }
                    throw ("-Connect could not establish either service. Security & Compliance: {0}. Exchange Online: {1}.{2}" -f [string]$connStatus.SecurityCompliance, [string]$connStatus.ExchangeOnline, $hint)
                }
                if (-not ($sccOk -and $exoOk)) {
                    Write-Verbose ("-Connect: one service connected (Security & Compliance: {0}; Exchange Online: {1}); proceeding - unreadable sections degrade honestly." -f [string]$connStatus.SecurityCompliance, [string]$connStatus.ExchangeOnline)
                }
            }
        }

        $meta = Get-PpaRunContext -Organization $Organization -AsOf $AsOf

        # ---- SESSION DIAGNOSTICS (Verbose) ----
        # Confirm the read sessions the collectors depend on. Run with -Verbose to see this and
        # every per-cmdlet outcome (Invoke-PpaReadCmdlet logs each Get-* call and its result/error).
        Write-Verbose 'Checking read sessions (run with -Verbose for full collector diagnostics)...'
        foreach ($probe in @('Get-Label', 'Get-DlpCompliancePolicy', 'Get-OrganizationConfig')) {
            $available = [bool](Get-Command -Name $probe -ErrorAction SilentlyContinue)
            Write-Verbose (" session cmdlet {0}: {1}" -f $probe, ($(if ($available) { 'available' } else { 'NOT available - session not connected?' })))
        }
        $conn = Invoke-PpaReadCmdlet -Name 'Get-ConnectionInformation'
        if ($conn.Status -eq 'Ok') {
            foreach ($c in @($conn.Data)) { Write-Verbose (" active connection: {0} ({1})" -f $c.ConnectionUri, $c.UserPrincipalName) }
            if (@($conn.Data).Count -eq 0) { Write-Verbose ' active connection: NONE (Connect-IPPSSession / Connect-ExchangeOnline not established)' }
        }

        # ---- COLLECT (read-only; a failure is captured, logged, and yields $null) ----
        $collectorErrors = @{}
        # Keyed by SECTION ID (the stable identifier) - the analyze stage below keys its
        # lookup off the same section id, so the real collector exception survives for ALL
        # eight sections. (Keying by display name silently missed the four sections whose
        # collect-name and title differ: Retention, Insider_Risk, Communication_Compliance,
        # DSPM_for_AI - the generic placeholder was shown instead of the real reason. F-002.)
        $collect = {
            param([string]$Id, [scriptblock]$Block)
            try { & $Block }
            catch {
                $collectorErrors[$Id] = $_.Exception.Message
                $display = ($Id -replace '_', ' ')
                Write-Warning ("Collector '{0}' failed: {1}" -f $display, $_.Exception.Message)
                Write-Verbose ("Collector '{0}' exception [{1}]:`n{2}`n{3}" -f $display, $_.Exception.GetType().FullName, $_.Exception.Message, $_.ScriptStackTrace)
                $null
            }
        }
        $rawLabels = & $collect 'Sensitivity_Labels'       { Get-PpaSensitivityLabels }
        $rawDlp    = & $collect 'Data_Loss_Prevention'     { Get-PpaDlp }
        $rawRet    = & $collect 'Retention'                { Get-PpaRetention }
        $rawIrm    = & $collect 'Insider_Risk'             { Get-PpaInsiderRisk }
        $rawAud    = & $collect 'Audit'                    { Get-PpaAudit }
        $rawEd     = & $collect 'eDiscovery'               { Get-PpaEdiscovery }
        $rawCc     = & $collect 'Communication_Compliance' { Get-PpaCommsCompliance }
        $rawDspm   = & $collect 'DSPM_for_AI'              { Get-PpaDspmAi }

        # Static map: license annotations (never detection - decision D9). The SIT tier
        # map went with the DLP-04 retirement (Wave 5 cleanup Part 4).
        $licMap = Get-PpaLicenseRequirements
        $hasSiteLabels = $false
        if ($rawLabels) {
            $hasSiteLabels = @($rawLabels.labels.items | Where-Object { $_.scopes -contains 'Site' -or $_.scopes -contains 'UnifiedGroup' }).Count -gt 0
        }

        # ---- ANALYZE (null raw or analyzer error -> Verify-manually error section) ----
        # The error section carries the REAL reason: the captured collector exception if the collector
        # threw, otherwise the analyzer's exception message - not a generic placeholder.
        $analyze = {
            param([string]$Id, [string]$Title, [string]$Group, [string]$Icon, [string]$Tag, $Raw, [scriptblock]$Block)
            if ($null -eq $Raw) {
                # Look up by section id - the SAME key $collect registers under (F-002).
                $why = if ($collectorErrors.ContainsKey($Id)) { "Collector error: " + $collectorErrors[$Id] } else { 'Collector returned no data (not connected, missing module, or access denied). Re-run with -Verbose for the underlying cmdlet errors.' }
                return (New-PpaErrorSection -Id $Id -Title $Title -Group $Group -GroupIcon $Icon -GroupTag $Tag -Message $why)
            }
            try { & $Block }
            catch {
                Write-Warning ("Analyzer '{0}' failed: {1}" -f $Title, $_.Exception.Message)
                Write-Verbose ("Analyzer '{0}' exception [{1}]:`n{2}`n{3}" -f $Title, $_.Exception.GetType().FullName, $_.Exception.Message, $_.ScriptStackTrace)
                New-PpaErrorSection -Id $Id -Title $Title -Group $Group -GroupIcon $Icon -GroupTag $Tag -Message ("Analyzer error: " + $_.Exception.Message)
            }
        }

        $sections = @(
            & $analyze 'Sensitivity_Labels' 'Sensitivity Labels' 'Microsoft Information Protection' 'fas fa-shield-alt' '' $rawLabels { Invoke-PpaLabelAnalyzer -Raw $Raw -AsOf $AsOf -LicenseMap $licMap }
            & $analyze 'Data_Loss_Prevention' 'Data Loss Prevention' 'Microsoft Information Protection' 'fas fa-shield-alt' '' $rawDlp { Invoke-PpaDlpAnalyzer -Raw $Raw -AsOf $AsOf -LicenseMap $licMap }
            & $analyze 'Retention' 'Retention & Records' 'Data Lifecycle & Records' 'fas fa-archive' '' $rawRet { Invoke-PpaRetentionAnalyzer -Raw $Raw -LicenseMap $licMap }
            & $analyze 'Insider_Risk' 'Insider Risk Management' 'Insider Risk' 'fas fa-user-secret' '' $rawIrm { Invoke-PpaInsiderRiskAnalyzer -Raw $Raw -LicenseMap $licMap }
            & $analyze 'Audit' 'Audit' 'Discovery & Response' 'fas fa-search' '' $rawAud { Invoke-PpaAuditAnalyzer -Raw $Raw -LicenseMap $licMap }
            & $analyze 'eDiscovery' 'eDiscovery' 'Discovery & Response' 'fas fa-search' '' $rawEd { Invoke-PpaEdiscoveryAnalyzer -Raw $Raw -LicenseMap $licMap }
            & $analyze 'Communication_Compliance' 'Communication Compliance' 'Insider Risk' 'fas fa-user-secret' '' $rawCc { Invoke-PpaCommsComplianceAnalyzer -Raw $Raw -LicenseMap $licMap }
            & $analyze 'DSPM_for_AI' 'DSPM for AI - Copilot Data Security' 'AI Security' 'fas fa-robot' 'NEW' $rawDspm { Invoke-PpaDspmAiAnalyzer -Raw $Raw -LicenseMap $licMap -HasSiteLabels:$hasSiteLabels }
        )

        # ---- RUN PROFILE FILTER (P5): applied after analysis, before assembly, so all
        # collectors/analyzers ran identically and only the report/export thins out ----
        $selection = Select-PpaSections -Sections $sections -IncludeSection $IncludeSection -ExcludeSection $ExcludeSection
        $sections  = @($selection.Sections)

        # ---- COLLECTOR -> SECTION MAP (keyed by section id): built once here and reused by
        # the degraded summary below, the coverage matrix, and the snapshot. ----
        $rawMap = @{
            Sensitivity_Labels       = $rawLabels
            Data_Loss_Prevention     = $rawDlp
            Retention                = $rawRet
            Insider_Risk             = $rawIrm
            Audit                    = $rawAud
            eDiscovery               = $rawEd
            Communication_Compliance = $rawCc
            DSPM_for_AI              = $rawDspm
        }

        # ---- DEGRADED-SECTION SUMMARY (visible without -Verbose) ----
        # A section is degraded if it hard-errored to an *-ERR stub OR its collector read did
        # not fully succeed (access denied / not connected / partial). Warning on the collector
        # outcome - not just *-ERR - means a not-connected or partial-role run is flagged
        # instead of silently rendering fabricated "0 / Improvement" gaps (F-001).
        $degradedOutcomes = @('AccessDenied', 'CmdletUnavailable', 'Failed', 'Partial')
        $degraded = @($sections | Where-Object {
            $sid = [string]$_.id
            (@($_.findings | Where-Object { $_.id -like '*-ERR' }).Count -gt 0) -or
            ($rawMap.ContainsKey($sid) -and $null -ne $rawMap[$sid] -and ($degradedOutcomes -contains [string]$rawMap[$sid].outcome))
        })
        if ($degraded.Count -gt 0) {
            Write-Warning ("{0} section(s) degraded - a read did not fully succeed (access denied / not connected / partial): {1}. Affected findings read 'Verify manually'; re-run with -Verbose for the underlying cmdlet failures." -f $degraded.Count, ((@($degraded).title) -join ', '))
        }

        # ---- LICENSE-CONTEXT NOTE (assume E5, annotate tier - decision D9) ----
        $licNote = if ($licMap -and $licMap.contextNote) { [string]$licMap.contextNote }
                   else { 'This report assumes Microsoft 365 E5 (or equivalent) licensing when judging Purview workloads and does not read the tenant subscriptions; findings marked Requires note the tier the feature needs.' }
        $licBlock = [pscustomobject]@{ note = $licNote }

        # ---- COVERAGE MATRIX (Wave 4 Part D): pure projection from collected data.
        # $rawMap was built once above (collector -> section map) and is reused here. ----
        $coverage = Get-PpaCoverageModel -RawMap $rawMap

        # ---- ASSEMBLE -> RENDER (HTML primary) + EXPORT (JSON) ----
        $normalized = ConvertTo-PpaNormalized -Meta $meta -Licensing $licBlock -Sections $sections -Coverage $coverage

        $stamp = $AsOf.ToUniversalTime().ToString('yyyyMMdd-HHmmss')
        $reportsDir = Join-Path (Join-Path $OutputDirectory "PurviewPosture-$stamp") 'reports'

        # Create the timestamped reports directory BEFORE any write (-Force is idempotent and creates
        # every missing parent). The path is absolute, so New-Item and the .NET writes agree on it.
        New-Item -ItemType Directory -Path $reportsDir -Force | Out-Null

        # Run manifest (F-008): metadata-only record of the cmdlets this run executed, written
        # alongside the report. Emitted here - after the reads are done and the dir exists, before
        # the render write - so a render failure still leaves the manifest (best-effort; only a
        # crash in the pure assemble stage above this point skips it), and it always lands on
        # degraded runs, which complete normally. Metadata only, so there is nothing to redact.
        $manifestPath = Export-PpaRunManifest -Directory $reportsDir -PpaVersion ([string]$meta.version)

        $htmlPath = Join-Path $reportsDir 'posture-report.html'
        $jsonPath = Join-Path $reportsDir 'posture-report.json'
        [System.IO.File]::WriteAllText($htmlPath, (Export-PpaHtmlReport -Normalized $normalized -ExcludedSections $selection.ExcludedTitles -Redact:$Redact -RedactNames:$RedactNames -LogoDataUri $logoDataUri), (New-Object System.Text.UTF8Encoding($false)))
        [void](Export-PpaJson -Normalized $normalized -Path $jsonPath)

        # ---- SNAPSHOT (Wave 4 Part B): versioned JSON capture alongside the HTML ----
        # Post-selection scope: only the selected sections' objects/findings are captured;
        # excluded sections' collectors record 'Skipped', a crashed collector 'Failed'
        # (attempted-and-errored; 'NotRun' is reserved for never-attempted).
        $snapshotPath = $null
        if (-not $NoSnapshot) {
            # $rawMap was built once above for the coverage matrix; the snapshot reuses it.
            $profileName = if ([string]::IsNullOrWhiteSpace($Profile)) { $null } else { [System.IO.Path]::GetFileNameWithoutExtension($Profile) }
            $snapModel = New-PpaSnapshotModel `
                -RawMap $rawMap -Sections $sections -Meta $meta `
                -CapturedAt $AsOf -SnapshotId ([guid]::NewGuid().ToString()) `
                -ProfileName $profileName `
                -SectionIds @($sections | ForEach-Object { [string]$_.id })
            $snapResult = Export-PpaSnapshot -Model $snapModel -Directory $reportsDir -RawMap $rawMap -IncludeRawCapture:$IncludeRawCapture
            $snapshotPath = $snapResult.SnapshotPath
        }

        # Only report success once both files are actually on disk. If a write failed, let the failure
        # surface instead of returning paths that do not exist.
        if (-not (Test-Path -LiteralPath $htmlPath) -or -not (Test-Path -LiteralPath $jsonPath)) {
            throw "Report generation did not write its output to '$reportsDir'."
        }

        Write-Host "Report : $htmlPath"
        Write-Host "JSON : $jsonPath"
        $result = [pscustomobject]@{ HtmlPath = $htmlPath; JsonPath = $jsonPath; SnapshotPath = $snapshotPath; ManifestPath = $manifestPath; Normalized = $normalized }

        # ---- SHOW (-Show, opt-in): open the finished report with its default handler.
        # Best-effort: a headless box (no browser / shell association) must not fail a
        # run that already produced its artifacts. (Invoke-Item rather than a
        # process-start cmdlet: the read-only guard bans that verb across Private and
        # Public, and the guard stays unmodified.) ----
        if ($Show -and (Test-Path -LiteralPath $htmlPath)) {
            try { Invoke-Item -LiteralPath $htmlPath -ErrorAction Stop }
            catch { Write-Warning ("-Show could not open the report ({0}). Open it manually: {1}" -f $_.Exception.Message, $htmlPath) }
        }

        return $result
    }
    finally {
        # Session teardown stays opt-in (F-007): nothing happens here unless the operator
        # passed -Disconnect - and then it runs even on a failed run.
        # Disconnect-PurviewPostureSession is best-effort by contract (it never throws),
        # so this finally can never mask the real error.
        if ($Disconnect) { Disconnect-PurviewPostureSession }
    }
}