Private/Update-FslRunHistory.ps1

function Update-FslRunHistory {
    <#
    .SYNOPSIS
        Persists a diagnostic run to the history folder and diffs it against the
        previous run of the same scope: current findings are tagged New/Persisting
        (with a consecutive-run counter), and Critical/Warning findings that
        disappeared come back as synthetic 'Resolved' Info findings.
 
        Findings match across runs on Category|Check|Target - messages carry
        volatile counters and are deliberately not part of the identity.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Internal helper that only runs after the caller explicitly opted in via -HistoryPath; writing the history file is its entire purpose.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [AllowEmptyCollection()]
        [object[]]$Finding = @(),

        [Parameter(Mandatory)]
        [string]$HistoryPath,

        [Parameter(Mandatory)]
        [string]$Scope,

        # Per-container store measurements of THIS run; persisted with the run
        # record and compared against the oldest retained run for the capacity
        # forecast.
        [AllowEmptyCollection()]
        [object[]]$StoreMetric = @(),

        [ValidateRange(1, 3650)]
        [int]$RetentionDays = 90
    )

    if (-not (Test-Path -LiteralPath $HistoryPath)) {
        New-Item -ItemType Directory -Path $HistoryPath -Force | Out-Null
    }

    # One history stream per scope (host or sorted fleet list), so alternating
    # between different targets never produces bogus diffs.
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $scopeHash = ([System.BitConverter]::ToString($sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Scope)))).Replace('-', '').Substring(0, 10).ToLowerInvariant()
    }
    finally {
        $sha.Dispose()
    }

    $scopeFiles = @(Get-ChildItem -LiteralPath $HistoryPath -Filter ("run-{0}-*.json" -f $scopeHash) -File -ErrorAction SilentlyContinue |
            Sort-Object Name)
    $previous = $null
    $previousFile = $scopeFiles | Select-Object -Last 1
    if ($previousFile) {
        try { $previous = Get-Content -LiteralPath $previousFile.FullName -Raw | ConvertFrom-Json }
        catch { Write-Warning ("History file '{0}' could not be read - continuing without a diff." -f $previousFile.Name) }
    }

    # Capacity-forecast baseline: the OLDEST retained run that carries store
    # metrics - a long measurement window keeps the growth rate stable.
    $baseline = $null
    foreach ($scopeFile in $scopeFiles) {
        try { $candidate = Get-Content -LiteralPath $scopeFile.FullName -Raw | ConvertFrom-Json }
        catch { continue }
        if ($candidate.StoreMetrics -and @($candidate.StoreMetrics).Count -gt 0) { $baseline = $candidate; break }
    }

    $prevMap = @{}
    if ($previous -and $previous.Findings) {
        foreach ($prevFinding in $previous.Findings) {
            $prevMap[('{0}|{1}|{2}' -f $prevFinding.Category, $prevFinding.Check, $prevFinding.Target)] = $prevFinding
        }
    }

    $output = New-Object System.Collections.Generic.List[object]
    $records = New-Object System.Collections.Generic.List[object]
    foreach ($currentFinding in $Finding) {
        $key = '{0}|{1}|{2}' -f $currentFinding.Category, $currentFinding.Check, $currentFinding.Target
        $persistCount = 1
        $status = ''
        if ($prevMap.ContainsKey($key)) {
            $status = 'Persisting'
            $persistCount = [int]$prevMap[$key].PersistCount + 1
            $prevMap.Remove($key)
        }
        elseif ($previous) {
            $status = 'New'
        }
        Add-Member -InputObject $currentFinding -NotePropertyName ChangeStatus -NotePropertyValue $status -Force

        # Only alert-worthy findings get the annotation - Pass/Info stay quiet.
        if ($previous -and $currentFinding.Severity -in @('Critical', 'Warning')) {
            if ($status -eq 'New') {
                $currentFinding.Message += ' (New since the last run.)'
            }
            elseif ($persistCount -gt 1) {
                $currentFinding.Message += (" (Persisting - seen in {0} consecutive runs.)" -f $persistCount)
            }
        }

        $records.Add([pscustomobject]@{
                Category     = [string]$currentFinding.Category
                Check        = [string]$currentFinding.Check
                Severity     = [string]$currentFinding.Severity
                Target       = [string]$currentFinding.Target
                Message      = [string]$currentFinding.Message
                PersistCount = $persistCount
            })
        $output.Add($currentFinding)
    }

    # Whatever alert-worthy finding is left in the previous map has disappeared.
    if ($previous) {
        foreach ($leftKey in @($prevMap.Keys)) {
            $gone = $prevMap[$leftKey]
            if ([string]$gone.Severity -notin @('Critical', 'Warning')) { continue }
            $oldMessage = [string]$gone.Message
            if ($oldMessage.Length -gt 160) { $oldMessage = $oldMessage.Substring(0, 160) + '...' }
            $resolvedFinding = New-FslFinding -Category ([string]$gone.Category) -Check ([string]$gone.Check) -Severity Info -Target ([string]$gone.Target) `
                -Message ("Resolved since the last run: the previous {0} finding is gone. Was: {1}" -f $gone.Severity, $oldMessage)
            Add-Member -InputObject $resolvedFinding -NotePropertyName ChangeStatus -NotePropertyValue 'Resolved' -Force
            $output.Add($resolvedFinding)
        }
    }

    # Capacity forecast: compare this run's store metrics against the oldest
    # retained run. Requires at least a day of history so hourly sensor runs
    # never produce noise rates; forecast findings are recomputed every run
    # and deliberately not persisted.
    if (@($StoreMetric).Count -gt 0 -and $baseline -and $baseline.StoreMetrics) {
        $baselineTime = $null
        try { $baselineTime = [datetime]::Parse([string]$baseline.Timestamp, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind) }
        catch { $baselineTime = $null }
        $deltaDays = 0.0
        if ($baselineTime) { $deltaDays = ((Get-Date) - $baselineTime).TotalDays }
        if ($deltaDays -ge 1) {
            $baselineByDisk = @{}
            foreach ($oldMetric in $baseline.StoreMetrics) { $baselineByDisk[[string]$oldMetric.Disk] = $oldMetric }
            foreach ($currentMetric in $StoreMetric) {
                $oldMetric = $baselineByDisk[[string]$currentMetric.Disk]
                if (-not $oldMetric) { continue }
                if ([double]$currentMetric.PercentOfMax -le 0) { continue }
                $growthGB = [double]$currentMetric.SizeGB - [double]$oldMetric.SizeGB
                if ($growthGB -le 0) { continue }
                $ratePerDay = $growthGB / $deltaDays
                if ($ratePerDay -le 0.01) { continue }
                $maxGB = [double]$currentMetric.SizeGB / ([double]$currentMetric.PercentOfMax / 100)
                $daysToFull = ($maxGB - [double]$currentMetric.SizeGB) / $ratePerDay
                if ($daysToFull -gt 30) { continue }
                $severity = 'Warning'
                if ($daysToFull -le 7) { $severity = 'Critical' }
                $label = [string]$currentMetric.UserName
                if (-not $label) { $label = [string]$currentMetric.Disk }
                $output.Add((New-FslFinding -Category ProfileStore -Check 'Capacity forecast' -Severity $severity -Target $label `
                            -Message ("Container grows ~{0} GB/day; at the current rate it reaches its maximum in ~{1} day(s) (currently {2}% of {3} GB)." -f [math]::Round($ratePerDay, 2), [math]::Round($daysToFull), $currentMetric.PercentOfMax, [math]::Round($maxGB, 1)) `
                            -Evidence ("Disk: {0}. Grew {1} GB over the last {2} day(s)." -f $currentMetric.Disk, [math]::Round($growthGB, 2), [math]::Round($deltaDays, 1)) `
                            -Recommendation 'Free space inside the profile (Remove-FslOrphanedOst, OneDrive cache, temp data) or raise the VHDX maximum before sign-ins fail - and check what is growing (Outlook OST, Teams cache).'))
            }
        }
    }

    $metricRecords = @()
    foreach ($metric in $StoreMetric) {
        if (-not $metric.Disk) { continue }
        $metricRecords += [pscustomobject]@{
            Disk         = [string]$metric.Disk
            UserName     = [string]$metric.UserName
            SizeGB       = [double]$metric.SizeGB
            PercentOfMax = [double]$metric.PercentOfMax
        }
    }
    $runRecord = [pscustomobject]@{
        Scope        = $Scope
        Timestamp    = (Get-Date).ToString('o')
        Findings     = $records
        StoreMetrics = $metricRecords
    }
    $fileName = 'run-{0}-{1}.json' -f $scopeHash, (Get-Date -Format 'yyyyMMdd-HHmmssfff')
    $runRecord | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $HistoryPath $fileName) -Encoding UTF8

    # Built-in retention: rotate run files past the window (all scopes).
    Get-ChildItem -LiteralPath $HistoryPath -Filter 'run-*.json' -File -ErrorAction SilentlyContinue |
        Where-Object LastWriteTime -lt (Get-Date).AddDays(-1 * $RetentionDays) |
        Remove-Item -Force -ErrorAction SilentlyContinue

    $output
}