Public/PrivilegedAccess/Update-EntraOpsClassificationModels.ps1

#Requires -Version 7.0
function Update-EntraOpsClassificationModels {

    <#
    .SYNOPSIS
        Update all EntraOps classification models and the Classification Explorer in one orchestrated run.

    .DESCRIPTION
        Runs every classification export in dependency order while parallelizing the independent work:

          * Lane "ApiPermissions" (sequential):
                Export-EntraOpsClassificationAppRoles -> Export-EntraOpsClassificationScopes -> Export-EntraOpsClassificationApiPermissions
          * Lane "EntraIdRoles" (sequential):
                Export-EntraOpsClassificationDirectoryRoles -> Export-EntraOpsClassificationDirectoryRolesFromMsftDocs -> Get-EntraOpsClassificationDirectoryRolesMismatchFromMsftDocs
          * Lane "IdentityGovernance": Export-EntraOpsClassificationIdentityGovernanceRoles
          * Lane "DeviceManagement": Export-EntraOpsClassificationDeviceManagementRoles
          * Lane "AzureRoles": Export-EntraOpsClassificationAzureRoles

        All five lanes run in parallel (Start-ThreadJob); the two multi-step lanes keep their internal order.
        After every lane has finished, Update-EntraOpsClassificationExplorerData (-Mode Standalone) regenerates the Classification Explorer
        bundle (embedded classification data, attack paths, EAM Map dataset and the git-log-based change history for
        the History view; the history part is best effort - it needs 'git' and a non-shallow clone - and never fails
        the overall run).

        A live progress bar and streamed per-lane output are shown during processing. At the end a clean summary is
        printed (and returned) that lists every unclassified action/permission per source and the Microsoft Docs
        mismatch, formatted so the lists can be copied straight into a classification JSON file or into an AI prompt.

    .PARAMETER RepoRoot
        Repository root containing the 'Scripts', 'Classification', 'EntraOps_Classification' and 'ClassificationExplorer'
        folders. Defaults to the parent of this script's folder, or the current location when run interactively.

    .PARAMETER IncludeCustomRoles
        Include custom role definitions for the role-based exports (Directory, Identity Governance, Device Management, Azure).

    .PARAMETER Tier0IncludedResourceScope
        Azure scopes treated as Control Plane (Tier 0). Passed to Export-EntraOpsClassificationAzureRoles.

    .PARAMETER Tier1IncludedResourceScope
        Azure scopes treated as Management Plane (Tier 1). Passed to Export-EntraOpsClassificationAzureRoles.

    .PARAMETER AzureClassificationFile
        Classification definition file used for Azure roles. Default "./EntraOps_Classification/Classification_Azure.json".

    .PARAMETER AzureRoleDefinitionScope
        Optional scope passed to Get-AzRoleDefinition (e.g. "/" for tenant-scoped built-in roles).

    .PARAMETER ResolveApiPermissionDisplayNames
        Resolve service principal display names from Microsoft Graph in the API permissions export.

    .PARAMETER SkipClassificationExplorerUpdate
        Do not run Update-EntraOpsClassificationExplorerData at the end.

    .PARAMETER SkipEntraOpsRepoSync
        Do not prompt to sync the Classification Explorer app source into the EntraOps repository
        (Sync-EntraOpsClassificationExplorerSource) at the end of the run.

    .PARAMETER SkipHistoryUpdate
        Do not refresh the Classification Explorer's git-log-based change history (data/history-data.js,
        used by the History view) as part of the Update-EntraOpsClassificationExplorerData run. Has no effect when
        -SkipClassificationExplorerUpdate is set. Useful to skip the (slower) git-log walk when iterating quickly.

    .PARAMETER Sequential
        Run the lanes one after another instead of in parallel (useful for troubleshooting).

    .EXAMPLE
        Update-EntraOpsClassificationModels

    .EXAMPLE
        Update-EntraOpsClassificationModels -IncludeCustomRoles $true -Tier0IncludedResourceScope "/providers/Microsoft.Management/managementGroups/Tier0"
    #>


    [cmdletbinding()]
    param
    (
        [Parameter(Mandatory = $false)]
        [string]$RepoRoot
        ,
        [Parameter(Mandatory = $false)]
        $IncludeCustomRoles = $False
        ,
        [Parameter(Mandatory = $false)]
        [string[]]$Tier0IncludedResourceScope = @()
        ,
        [Parameter(Mandatory = $false)]
        [string[]]$Tier1IncludedResourceScope = @()
        ,
        [Parameter(Mandatory = $false)]
        [string]$AzureClassificationFile = "./EntraOps_Classification/Classification_Azure.json"
        ,
        [Parameter(Mandatory = $false)]
        $AzureRoleDefinitionScope = $null
        ,
        [Parameter(Mandatory = $false)]
        [switch]$ResolveApiPermissionDisplayNames
        ,
        [Parameter(Mandatory = $false)]
        [switch]$SkipClassificationExplorerUpdate
        ,
        [Parameter(Mandatory = $false)]
        [switch]$SkipEntraOpsRepoSync
        ,
        [Parameter(Mandatory = $false)]
        [switch]$SkipHistoryUpdate
        ,
        [Parameter(Mandatory = $false)]
        [switch]$Sequential
    )

    #region Resolve paths
    if ([string]::IsNullOrEmpty($RepoRoot)) {
        if ($PSScriptRoot) {
            $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
        } else {
            $RepoRoot = (Get-Location).Path
        }
    }
    $RepoRoot = (Resolve-Path $RepoRoot).Path
    $ScriptsPath = Join-Path $RepoRoot "Scripts"
    $ClassificationPath = Join-Path $RepoRoot "Classification"
    $ExplorerScript = Join-Path $RepoRoot "Scripts/Update-EntraOpsClassificationExplorerData.ps1"
    $SyncScript = Join-Path $RepoRoot "Scripts/Sync-EntraOpsClassificationExplorerSource.ps1"

    if (-not (Test-Path $ScriptsPath)) { throw "Scripts folder not found at '$ScriptsPath'. Provide -RepoRoot." }
    #endregion

    #region Pre-flight connection hints (do not fail, just warn)
    if (-not (Get-Command Get-MgContext -ErrorAction SilentlyContinue) -or $null -eq (Get-MgContext -ErrorAction SilentlyContinue)) {
        Write-Warning "No Microsoft Graph context detected. Run Connect-MgGraph before this command for the Graph based exports."
    }
    if (-not (Get-Command Get-AzContext -ErrorAction SilentlyContinue) -or $null -eq (Get-AzContext -ErrorAction SilentlyContinue)) {
        Write-Warning "No Azure context detected. Run Connect-AzAccount before this command for the Azure roles export."
    }
    #endregion

    $MismatchTempFile = Join-Path ([System.IO.Path]::GetTempPath()) ("EntraOpsDirectoryRolesMismatch_{0}.json" -f ([guid]::NewGuid()))

    $Options = @{
        IncludeCustomRoles               = [bool]$IncludeCustomRoles
        Tier0IncludedResourceScope       = $Tier0IncludedResourceScope
        Tier1IncludedResourceScope       = $Tier1IncludedResourceScope
        AzureClassificationFile          = $AzureClassificationFile
        AzureRoleDefinitionScope         = $AzureRoleDefinitionScope
        ResolveApiPermissionDisplayNames = [bool]$ResolveApiPermissionDisplayNames
        MismatchTempFile                 = $MismatchTempFile
    }

    try {
    #region Lane definitions
    $LaneApiPermissions = {
        param($RepoRoot, $ScriptsPath, $Options)
        Set-Location $RepoRoot
        . (Join-Path $ScriptsPath "Export-EntraOpsClassificationAppRoles.ps1")
        . (Join-Path $ScriptsPath "Export-EntraOpsClassificationScopes.ps1")
        . (Join-Path $ScriptsPath "Export-EntraOpsClassificationApiPermissions.ps1")

        try { Write-Output "Step 1/3 Export-EntraOpsClassificationAppRoles"; Export-EntraOpsClassificationAppRoles } catch { throw "AppRoles failed: $($_.Exception.Message)" }
        try { Write-Output "Step 2/3 Export-EntraOpsClassificationScopes"; Export-EntraOpsClassificationScopes } catch { throw "Scopes failed: $($_.Exception.Message)" }
        try {
            Write-Output "Step 3/3 Export-EntraOpsClassificationApiPermissions"
            if ($Options.ResolveApiPermissionDisplayNames) { Export-EntraOpsClassificationApiPermissions -ResolveDisplayNames }
            else { Export-EntraOpsClassificationApiPermissions }
        } catch { throw "ApiPermissions failed: $($_.Exception.Message)" }
        Write-Output "Lane complete"
    }

    $LaneEntraIdRoles = {
        param($RepoRoot, $ScriptsPath, $Options)
        Set-Location $RepoRoot
        . (Join-Path $ScriptsPath "Export-EntraOpsClassificationDirectoryRoles.ps1")
        . (Join-Path $ScriptsPath "Export-EntraOpsClassificationDirectoryRolesFromMsftDocs.ps1")
        . (Join-Path $ScriptsPath "Get-EntraOpsClassificationDirectoryRolesMismatchFromMsftDocs.ps1")

        try { Write-Output "Step 1/3 Export-EntraOpsClassificationDirectoryRoles"; Export-EntraOpsClassificationDirectoryRoles -IncludeCustomRoles $Options.IncludeCustomRoles } catch { throw "DirectoryRoles failed: $($_.Exception.Message)" }
        try { Write-Output "Step 2/3 Export-EntraOpsClassificationDirectoryRolesFromMsftDocs"; Export-EntraOpsClassificationDirectoryRolesFromMsftDocs } catch { throw "DirectoryRolesFromMsftDocs failed: $($_.Exception.Message)" }
        try {
            Write-Output "Step 3/3 Get-EntraOpsClassificationDirectoryRolesMismatchFromMsftDocs"
            $mm = Get-EntraOpsClassificationDirectoryRolesMismatchFromMsftDocs -ShowSummaryOnly $true
            $mm | ConvertTo-Json -Depth 8 | Out-File -FilePath $Options.MismatchTempFile -Force
        } catch { throw "Mismatch comparison failed: $($_.Exception.Message)" }
        Write-Output "Lane complete"
    }

    $LaneIdentityGovernance = {
        param($RepoRoot, $ScriptsPath, $Options)
        Set-Location $RepoRoot
        . (Join-Path $ScriptsPath "Export-EntraOpsClassificationIdentityGovernanceRoles.ps1")
        try { Write-Output "Export-EntraOpsClassificationIdentityGovernanceRoles"; Export-EntraOpsClassificationIdentityGovernanceRoles -IncludeCustomRoles $Options.IncludeCustomRoles } catch { throw "IdentityGovernanceRoles failed: $($_.Exception.Message)" }
        Write-Output "Lane complete"
    }

    $LaneDeviceManagement = {
        param($RepoRoot, $ScriptsPath, $Options)
        Set-Location $RepoRoot
        . (Join-Path $ScriptsPath "Export-EntraOpsClassificationDeviceManagementRoles.ps1")
        try { Write-Output "Export-EntraOpsClassificationDeviceManagementRoles"; Export-EntraOpsClassificationDeviceManagementRoles -IncludeCustomRoles $Options.IncludeCustomRoles } catch { throw "DeviceManagementRoles failed: $($_.Exception.Message)" }
        Write-Output "Lane complete"
    }

    $LaneAzureRoles = {
        param($RepoRoot, $ScriptsPath, $Options)
        Set-Location $RepoRoot
        . (Join-Path $ScriptsPath "Export-EntraOpsClassificationAzureRoles.ps1")
        $azParams = @{ IncludeCustomRoles = $Options.IncludeCustomRoles }
        if ($Options.Tier0IncludedResourceScope.Count -gt 0) { $azParams.Tier0IncludedResourceScope = $Options.Tier0IncludedResourceScope }
        if ($Options.Tier1IncludedResourceScope.Count -gt 0) { $azParams.Tier1IncludedResourceScope = $Options.Tier1IncludedResourceScope }
        if ($Options.AzureClassificationFile) { $azParams.ClassificationFile = $Options.AzureClassificationFile }
        if ($Options.AzureRoleDefinitionScope) { $azParams.RoleDefinitionScope = $Options.AzureRoleDefinitionScope }
        try { Write-Output "Export-EntraOpsClassificationAzureRoles"; Export-EntraOpsClassificationAzureRoles @azParams } catch { throw "AzureRoles failed: $($_.Exception.Message)" }
        Write-Output "Lane complete"
    }

    $Lanes = @(
        [PSCustomObject]@{ Name = "ApiPermissions"; Script = $LaneApiPermissions }
        [PSCustomObject]@{ Name = "EntraIdRoles"; Script = $LaneEntraIdRoles }
        [PSCustomObject]@{ Name = "IdentityGovernance"; Script = $LaneIdentityGovernance }
        [PSCustomObject]@{ Name = "DeviceManagement"; Script = $LaneDeviceManagement }
        [PSCustomObject]@{ Name = "AzureRoles"; Script = $LaneAzureRoles }
    )
    #endregion

    $StartTime = Get-Date
    Write-Host ""
    Write-Host "=== Updating EntraOps classification models ===" -ForegroundColor Cyan
    Write-Host ("Repository root : {0}" -f $RepoRoot) -ForegroundColor DarkCyan
    Write-Host ("Execution mode : {0}" -f $(if ($Sequential) { "Sequential" } else { "Parallel (Start-ThreadJob)" })) -ForegroundColor DarkCyan
    Write-Host ("Include custom : {0}" -f $Options.IncludeCustomRoles) -ForegroundColor DarkCyan
    Write-Host ""

    #region Run lanes
    $UseParallel = (-not $Sequential) -and (Get-Command Start-ThreadJob -ErrorAction SilentlyContinue)
    if ((-not $Sequential) -and (-not $UseParallel)) {
        Write-Warning "Start-ThreadJob not available, falling back to sequential execution."
    }

    if ($UseParallel) {
        $Jobs = foreach ($Lane in $Lanes) {
            Start-ThreadJob -Name $Lane.Name -ScriptBlock $Lane.Script -ArgumentList $RepoRoot, $ScriptsPath, $Options
        }

        $TotalLanes = $Jobs.Count
        do {
            Start-Sleep -Milliseconds 750
            foreach ($Job in $Jobs) {
                $NewOutput = Receive-Job -Job $Job
                foreach ($Line in $NewOutput) {
                    Write-Host ("[{0,-18}] {1}" -f $Job.Name, $Line) -ForegroundColor DarkGray
                }
            }
            $Completed = @($Jobs | Where-Object { $_.State -in 'Completed', 'Failed', 'Stopped' }).Count
            $Running = (@($Jobs | Where-Object { $_.State -eq 'Running' }) | ForEach-Object { $_.Name }) -join ", "
            Write-Progress -Activity "Updating EntraOps classification models" -Status "$Completed of $TotalLanes lanes complete | running: $Running" -PercentComplete (($Completed / $TotalLanes) * 100)
        } while (@($Jobs | Where-Object { $_.State -eq 'Running' }).Count -gt 0)

        # Drain remaining output and retain failures before removing the jobs.
        $LaneFailures = [System.Collections.Generic.List[string]]::new()
        foreach ($Job in $Jobs) {
            $NewOutput = Receive-Job -Job $Job
            foreach ($Line in $NewOutput) {
                Write-Host ("[{0,-18}] {1}" -f $Job.Name, $Line) -ForegroundColor DarkGray
            }
            if ($Job.State -in 'Failed', 'Stopped') {
                $Reason = $Job.ChildJobs[0].JobStateInfo.Reason
                $Message = if ($Reason) { $Reason.Message } else { "Job ended in state '$($Job.State)'" }
                $LaneFailures.Add(("{0}: {1}" -f $Job.Name, $Message))
            }
        }
        $Jobs | Remove-Job -Force
        Write-Progress -Activity "Updating EntraOps classification models" -Completed
        if ($LaneFailures.Count -gt 0) {
            throw ("Classification update failed in {0} lane(s): {1}" -f $LaneFailures.Count, ($LaneFailures -join '; '))
        }
    } else {
        $TotalLanes = $Lanes.Count
        $Index = 0
        foreach ($Lane in $Lanes) {
            $Index++
            Write-Progress -Activity "Updating EntraOps classification models (sequential)" -Status "Lane $Index of $TotalLanes : $($Lane.Name)" -PercentComplete ((($Index - 1) / $TotalLanes) * 100)
            Write-Host ("--- Lane: {0} ---" -f $Lane.Name) -ForegroundColor Cyan
            & $Lane.Script $RepoRoot $ScriptsPath $Options | ForEach-Object { Write-Host ("[{0,-18}] {1}" -f $Lane.Name, $_) -ForegroundColor DarkGray }
        }
        Write-Progress -Activity "Updating EntraOps classification models (sequential)" -Completed
    }
    #endregion

    #region Update Classification Explorer
    if (-not $SkipClassificationExplorerUpdate) {
        if (Test-Path $ExplorerScript) {
            Write-Host ""
            Write-Host "=== Updating Classification Explorer (Update-EntraOpsClassificationExplorerData -Mode Standalone) ===" -ForegroundColor Cyan
            Write-Progress -Activity "Updating EntraOps classification models" -Status "Refreshing Classification Explorer bundle" -PercentComplete 95
            try {
                . $ExplorerScript
                $explorerParams = @{ RepoRoot = $RepoRoot; Mode = 'Standalone' }
                if ($SkipHistoryUpdate) { $explorerParams.SkipHistory = $true }
                Update-EntraOpsClassificationExplorerData @explorerParams
            } catch { throw "Update-EntraOpsClassificationExplorerData failed: $($_.Exception.Message)" }
            Write-Progress -Activity "Updating EntraOps classification models" -Completed
        } else {
            throw "Classification Explorer update script not found at '$ExplorerScript'."
        }
    }
    #endregion

    #region Sync Classification Explorer source to EntraOps repository
    if (-not $SkipEntraOpsRepoSync) {
        if (Test-Path $SyncScript) {
            $DefaultEntraOpsRoot = "../entraops"
            Write-Host ""
            Write-Host "=== Sync Classification Explorer source to EntraOps repository ===" -ForegroundColor Cyan
            $EntraOpsRootAnswer = Read-Host ("Update Classification Explorer in EntraOps repository as well? Default EntraOpsRoot: '{0}' (press Enter to accept, type a custom path, or 'n' to skip)" -f $DefaultEntraOpsRoot)
            if ($EntraOpsRootAnswer -notin @('n', 'N', 'no', 'No')) {
                $EntraOpsRootToUse = if ([string]::IsNullOrWhiteSpace($EntraOpsRootAnswer)) { $DefaultEntraOpsRoot } else { $EntraOpsRootAnswer }
                try {
                    . $SyncScript
                    Sync-EntraOpsClassificationExplorerSource -RepoRoot $RepoRoot -EntraOpsRoot $EntraOpsRootToUse
                } catch { throw "Sync-EntraOpsClassificationExplorerSource failed: $($_.Exception.Message)" }
            } else {
                Write-Host "Skipped syncing Classification Explorer source to EntraOps repository." -ForegroundColor DarkGray
            }
        } else {
            throw "Sync-EntraOpsClassificationExplorerSource script not found at '$SyncScript'."
        }
    }
    #endregion

    #region Build summary
    function Get-EntraOpsUnclassifiedFromFile {
        param([string]$Path)
        $items = New-Object System.Collections.Generic.List[string]
        if (-not (Test-Path $Path)) { return $items }
        try { $data = Get-Content -Path $Path -Raw | ConvertFrom-Json -Depth 20 } catch { return $items }
        # ConvertFrom-Json collapses a JSON '[]' to $null and a single-element JSON array to a
        # bare object rather than a one-item array - normalize both back to a real array so the
        # foreach below always sees the same shape regardless of how many entries the file has.
        $data = @(if ($null -ne $data) { $data })
        foreach ($item in $data) {
            $props = $item.PSObject.Properties.Name
            if ($props -contains 'RolePermissions') {
                foreach ($perm in $item.RolePermissions) {
                    $permProps = $perm.PSObject.Properties.Name
                    if (($permProps -contains 'EAMTierLevelName') -and $perm.EAMTierLevelName -eq 'Unclassified' -and ($permProps -contains 'AuthorizedResourceAction') -and $perm.AuthorizedResourceAction) {
                        $items.Add([string]$perm.AuthorizedResourceAction) | Out-Null
                    }
                }
            } elseif (($props -contains 'EAMTierLevelName') -and $item.EAMTierLevelName -eq 'Unclassified') {
                $id = $null
                foreach ($candidate in 'AuthorizedResourceAction', 'PermissionValue', 'AppRoleDisplayName', 'ScopeDisplayName') {
                    if (($props -contains $candidate) -and $item.$candidate) { $id = [string]$item.$candidate; break }
                }
                if ($id) { $items.Add($id) | Out-Null }
            }
        }
        return ($items | Sort-Object -Unique)
    }

    $SummarySources = [ordered]@{
        "Classification_AppRoles.json"                          = (Join-Path $ClassificationPath "Classification_AppRoles.json")
        "Classification_Scopes.json"                            = (Join-Path $ClassificationPath "Classification_Scopes.json")
        "Classification_ApiPermissions.json"                    = (Join-Path $ClassificationPath "Classification_ApiPermissions.json")
        "Classification_EntraIdDirectoryRoles.json"             = (Join-Path $ClassificationPath "Classification_EntraIdDirectoryRoles.json")
        "Classification_EntraIdDirectoryRolesFromMsftDocs.json" = (Join-Path $ClassificationPath "Classification_EntraIdDirectoryRolesFromMsftDocs.json")
        "Classification_IdentityGovernance.json"                = (Join-Path $ClassificationPath "Classification_IdentityGovernance.json")
        "Classification_DeviceManagementRoles.json"             = (Join-Path $ClassificationPath "Classification_DeviceManagementRoles.json")
        "Classification_AzureResources.json"                    = (Join-Path $ClassificationPath "Classification_AzureResources.json")
    }

    $UnclassifiedBySource = [ordered]@{}
    foreach ($Source in $SummarySources.Keys) {
        $UnclassifiedBySource[$Source] = @(Get-EntraOpsUnclassifiedFromFile -Path $SummarySources[$Source])
    }

    # Microsoft Docs mismatch
    $Mismatch = $null
    if (Test-Path $MismatchTempFile) {
        try { $Mismatch = Get-Content -Path $MismatchTempFile -Raw | ConvertFrom-Json -Depth 10 } catch { $Mismatch = $null }
        Remove-Item -Path $MismatchTempFile -Force -ErrorAction SilentlyContinue
    }

    $OnlyInDocs = @()
    $OnlyInGraph = @()
    $DocsUnclassified = @()
    if ($Mismatch) {
        $OnlyInDocs = @($Mismatch.ActionDifferences | Where-Object { $_.Difference -eq 'OnlyInDocs' } | Select-Object -ExpandProperty Action -Unique | Sort-Object)
        $OnlyInGraph = @($Mismatch.ActionDifferences | Where-Object { $_.Difference -eq 'OnlyInGraph' } | Select-Object -ExpandProperty Action -Unique | Sort-Object)
        $DocsUnclassified = @($Mismatch.UnclassifiedActions | Select-Object -ExpandProperty Action -Unique | Sort-Object)
    }
    #endregion

    #region Print summary
    $Duration = (Get-Date) - $StartTime
    Write-Host ""
    Write-Host "================ CLASSIFICATION UPDATE SUMMARY ================" -ForegroundColor Cyan
    Write-Host ("Completed in {0:hh\:mm\:ss}" -f $Duration) -ForegroundColor Cyan
    Write-Host ""
    Write-Host "Unclassified items per source:" -ForegroundColor Yellow
    foreach ($Source in $UnclassifiedBySource.Keys) {
        $count = $UnclassifiedBySource[$Source].Count
        $color = if ($count -gt 0) { "Yellow" } else { "Green" }
        Write-Host (" {0,-52} {1}" -f $Source, $count) -ForegroundColor $color
    }
    Write-Host ""
    Write-Host "Microsoft Docs comparison (Entra ID directory roles):" -ForegroundColor Yellow
    Write-Host (" Actions only in Graph export : {0}" -f $OnlyInGraph.Count) -ForegroundColor Yellow
    Write-Host (" Actions only in Docs export : {0}" -f $OnlyInDocs.Count) -ForegroundColor Magenta
    Write-Host (" Unclassified (Graph+Docs) : {0}" -f $DocsUnclassified.Count) -ForegroundColor Red

    # Copy-friendly blocks (plain text, no markdown, safe to paste into JSON or an AI prompt)
    Write-Host ""
    Write-Host "----- COPY/PASTE: UNCLASSIFIED ACTIONS PER SOURCE -----" -ForegroundColor Green
    foreach ($Source in $UnclassifiedBySource.Keys) {
        $list = $UnclassifiedBySource[$Source]
        if ($list.Count -gt 0) {
            Write-Host ""
            Write-Host ("# {0} ({1} unclassified)" -f $Source, $list.Count) -ForegroundColor Green
            $list | ForEach-Object { Write-Host $_ }
        }
    }

    if ($DocsUnclassified.Count -gt 0) {
        Write-Host ""
        Write-Host "----- COPY/PASTE: UNCLASSIFIED DIRECTORY ROLE ACTIONS (Graph + Docs) -----" -ForegroundColor Green
        $DocsUnclassified | ForEach-Object { Write-Host $_ }
    }

    if ($OnlyInDocs.Count -gt 0) {
        Write-Host ""
        Write-Host "----- COPY/PASTE: ACTIONS PRESENT ONLY IN MICROSOFT DOCS (missing from Graph export) -----" -ForegroundColor Green
        $OnlyInDocs | ForEach-Object { Write-Host $_ }
    }
    Write-Host ""
    Write-Host "==============================================================" -ForegroundColor Cyan
    Write-Host ""
    #endregion

    # Return a structured object for further processing / AI prompting
    return [PSCustomObject]@{
        RepoRoot              = $RepoRoot
        DurationSeconds       = [math]::Round($Duration.TotalSeconds, 1)
        UnclassifiedBySource  = $UnclassifiedBySource
        TotalUnclassified     = (($UnclassifiedBySource.Values | ForEach-Object { $_.Count }) | Measure-Object -Sum).Sum
        DocsOnlyActions       = $OnlyInDocs
        GraphOnlyActions      = $OnlyInGraph
        DirectoryUnclassified = $DocsUnclassified
        ActionDifferenceCount = if ($Mismatch) { @($Mismatch.ActionDifferences).Count } else { 0 }
    }
    } finally {
        Remove-Item -LiteralPath $MismatchTempFile -Force -ErrorAction SilentlyContinue
    }
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCD43VbwhqUKkcoy
# vOE4FzvX9L3iGP9qhGqxDjhIJFCSVKCCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# AEkoya49fu0nMA0GCSqGSIb3DQEBDAUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQK
# ExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2Vy
# dGlmaWNhdGlvbiBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVkIE5l
# dHdvcmsgQ0EwHhcNMjEwNTMxMDY0MzA2WhcNMjkwOTE3MDY0MzA2WjCBgDELMAkG
# A1UEBhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAl
# BgNVBAsTHkNlcnR1bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMb
# Q2VydHVtIFRydXN0ZWQgTmV0d29yayBDQSAyMIICIjANBgkqhkiG9w0BAQEFAAOC
# Ag8AMIICCgKCAgEAvfl4+ObVgAxknYYblmRnPyI6HnUBfe/7XGeMycxca6mR5rlC
# 5SBLm9qbe7mZXdmbgEvXhEArJ9PoujC7Pgkap0mV7ytAJMKXx6fumyXvqAoAl4Va
# qp3cKcniNQfrcE1K1sGzVrihQTib0fsxf4/gX+GxPw+OFklg1waNGPmqJhCrKtPQ
# 0WeNG0a+RzDVLnLRxWPa52N5RH5LYySJhi40PylMUosqp8DikSiJucBb+R3Z5yet
# /5oCl8HGUJKbAiy9qbk0WQq/hEr/3/6zn+vZnuCYI+yma3cWKtvMrTscpIfcRnNe
# GWJoRVfkkIJCu0LW8GHgwaM9ZqNd9BjuiMmNF0UpmTJ1AjHuKSbIawLmtWJFfzcV
# WiNoidQ+3k4nsPBADLxNF8tNorMe0AZa3faTz1d1mfX6hhpneLO/lv403L3nUlbl
# s+V1e9dBkQXcXWnjlQ1DufyDljmVe2yAWk8TcsbXfSl6RLpSpCrVQUYJIP4ioLZb
# MI28iQzV13D4h1L92u+sUS4Hs07+0AnacO+Y+lbmbdu1V0vc5SwlFcieLnhO+Nqc
# noYsylfzGuXIkosagpZ6w7xQEmnYDlpGizrrJvojybawgb5CAKT41v4wLsfSRvbl
# jnX98sy50IdbzAYQYLuDNbdeZ95H7JlI8aShFf6tjGKOOVVPORa5sWOd/7cCAwEA
# AaOCAT4wggE6MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLahVDkCw6A/joq8
# +tT4HKbROg79MB8GA1UdIwQYMBaAFAh2zcsH/yT2xc3tu5C84oQ3RnX3MA4GA1Ud
# DwEB/wQEAwIBBjAvBgNVHR8EKDAmMCSgIqAghh5odHRwOi8vY3JsLmNlcnR1bS5w
# bC9jdG5jYS5jcmwwawYIKwYBBQUHAQEEXzBdMCgGCCsGAQUFBzABhhxodHRwOi8v
# c3ViY2Eub2NzcC1jZXJ0dW0uY29tMDEGCCsGAQUFBzAChiVodHRwOi8vcmVwb3Np
# dG9yeS5jZXJ0dW0ucGwvY3RuY2EuY2VyMDkGA1UdIAQyMDAwLgYEVR0gADAmMCQG
# CCsGAQUFBwIBFhhodHRwOi8vd3d3LmNlcnR1bS5wbC9DUFMwDQYJKoZIhvcNAQEM
# BQADggEBAFHCoVgWIhCL/IYx1MIy01z4S6Ivaj5N+KsIHu3V6PrnCA3st8YeDrJ1
# BXqxC/rXdGoABh+kzqrya33YEcARCNQOTWHFOqj6seHjmOriY/1B9ZN9DbxdkjuR
# mmW60F9MvkyNaAMQFtXx0ASKhTP5N+dbLiZpQjy6zbzUeulNndrnQ/tjUoCFBMQl
# lVXwfqefAcVbKPjgzoZwpic7Ofs4LphTZSJ1Ldf23SIikZbr3WjtP6MZl9M7JYjs
# NhI9qX7OAo0FmpKnJ25FspxihjcNpDOO16hO0EoXQ0zF8ads0h5YbBRRfopUofbv
# n3l6XYGaFpAP4bvxSgD5+d2+7arszgowggZrMIIEU6ADAgECAhBX5gfP8LabvnxC
# XoItEBa/MA0GCSqGSIb3DQEBCwUAMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhB
# c3NlY28gRGF0YSBTeXN0ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBDb2RlIFNp
# Z25pbmcgMjAyMSBDQTAeFw0yNjEwMDYwODI4MjNaFw0yNzEwMDYwODI4MjJaMIGQ
# MQswCQYDVQQGEwJERTEdMBsGA1UECAwUUmhpbmVsYW5kLVBhbGF0aW5hdGUxEjAQ
# BgNVBAcMCUxhaG5zdGVpbjEeMBwGA1UECgwVT3BlbiBTb3VyY2UgRGV2ZWxvcGVy
# MS4wLAYDVQQDDCVPcGVuIFNvdXJjZSBEZXZlbG9wZXIgVGhvbWFzIE5hdW5oZWlt
# MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAphp2ljSmVsRB6gvkEq7A
# DUMENpKZiDFoAfF1ktD1nCwaVyI+bJ0ROjHwAn4iJgS+vlBDPOUIw2GLHkiqUhOg
# HSwzg6gj45GNmt6zxsKP0zPlhMB/fb0zmz7/F7e3yfKCXLfhbHuk0Es9gJ+8Wmet
# 9StVwwSE/K0MpW06GKA9nPWyE4N/ADwigDIj9rsTKJR4cve9w8G1b3zohpUc/qMr
# onab+HVw5r9b3sOBnnaXC6zvrX4fovRz1ztwfWjRjILLoFG6mW/TuK5F2ICD3Rhl
# 9R2rJWWWhzTDMsQlzvX+/SaM74q3OFpIVNdhvtmWU9pKUBRAyYMijcDqKEFGSzrc
# 8JFmGMpP5TuroWF+N21d3px2eARvW1mV0DCfFAH+Og20SH/gVW6/ltQQEkH/z/Zt
# 49ARyJrgESLOQtlMCIYSoKISdkk4ml4VUS1E/PcbzXEYj3a7m6DOxcd3WuY03UT9
# cnk3p0LMN6LeLYAZgVY+HRloZlH+kW2SkOHNuWgtXvZhAgMBAAGjggF4MIIBdDAM
# BgNVHRMBAf8EAjAAMD0GA1UdHwQ2MDQwMqAwoC6GLGh0dHA6Ly9jY3NjYTIwMjEu
# Y3JsLmNlcnR1bS5wbC9jY3NjYTIwMjEuY3JsMHMGCCsGAQUFBwEBBGcwZTAsBggr
# BgEFBQcwAYYgaHR0cDovL2Njc2NhMjAyMS5vY3NwLWNlcnR1bS5jb20wNQYIKwYB
# BQUHMAKGKWh0dHA6Ly9yZXBvc2l0b3J5LmNlcnR1bS5wbC9jY3NjYTIwMjEuY2Vy
# MB8GA1UdIwQYMBaAFN10XUwA23ufoHTKsW73PMAywHDNMB0GA1UdDgQWBBTBvGyH
# MXj1dMbVnxuof9oP4PY7cDBLBgNVHSAERDBCMAgGBmeBDAEEATA2BgsqhGgBhvZ3
# AgUBBDAnMCUGCCsGAQUFBwIBFhlodHRwczovL3d3dy5jZXJ0dW0ucGwvQ1BTMBMG
# A1UdJQQMMAoGCCsGAQUFBwMDMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsF
# AAOCAgEAkC165c7/r6y3VeEEpNXas9bQs6ZpKPdJffHkf9xoPi4kWvAIWGc4ocG1
# DJLb18RXiDYShDmT5sb/6I08FeipKi2DNJkuOq6yOTJ3S7+hNFIZCyd6i7WKO8ea
# P5I+FAbyJP4+5coZoMg45bUa9JoocaJCm+5n7WiCfN7/EQpClW5Trm4+ZMbp7EMN
# F1uLJgGCMnN0SsoUarp7PHlqupMxMC4Uhhe+lAP2ytcSIU81L97/qjrADbLWqICk
# 9il5iGM+aZndguq/cmfH9rJj4GRZHWnG6WwnzYD9+KgYlRI6em+/9dq+IRiXe2Bs
# thUuIaFW7U2gWw8tBTEYm1UBLHdJx+L1gjJCLs5KT/wy5wU32wZxNDqPUQejUC1x
# GvU3cKqmLgRq+yNf3gFVfzqIoggyM3U9wQvnk7nBtCkpjXS2hdx4yxzcmrlWsDHr
# z6+6/jeyQySzbYUlFmKnc4n9Mw2soHOajEua5poFARVRS5KgUBuiiQ21YFNYzWBo
# qGuECaaAI/C5rGxapMJ3dAOBnHw6NLqBet+ZXBJEccX5y89MLlOQu+jDmJP8HieR
# qDLlwfhVZnw4RY8Rus43NZBwhNlVuOUXqocHKmJhKNlekda3qsJj93eQBQOxKJcc
# w5O08q9jukpH2+yzFAj+7ll33DuNUgWptogc4Awtbx3glzcEvJQwggaCMIIEaqAD
# AgECAhAo8HfBHDa9/l90MkdwJy4DMA0GCSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYT
# AlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0ZW1zIFMuQS4xJDAiBgNVBAMT
# G0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQTAeFw0yNjAzMTEwNzM0NTRaFw0z
# NjAyMjcwNzM0NTRaMFAxCzAJBgNVBAYTAlBMMSEwHwYDVQQKDBhBc3NlY28gRGF0
# YSBTeXN0ZW1zIFMuQS4xHjAcBgNVBAMMFUNlcnR1bSBUaW1lc3RhbXAgMjAyNjCC
# AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALi9zfBw+xI12hi2F9uNQBNO
# STkTIi13fuh3DiWybI5392bBfJw6tO7zDs7UqP/rejVf4xFTU3XNOPsuOFkP5SPF
# snWwn3VJL+NhNhsuujvYsQtaQnvNo1wk46reTgws1L8OFEdKaxgyqT4zOQx81mLw
# NZSqXNU9Rrb9oAJWJF4Mydz+C1ebu0D0+vgM3tqAQ8TU203fqgKCPqIdHloaFqH9
# eyR+8tnpmy2hnnN59ZOtmsuHeRwf5iTOhlhf0d/qqquLOSIGPgGVL1Hj5XBUox2O
# E7tQRr+l/xxNbL6OuwBO4Aa2EbuxYg37cpXv5i2/lTL4NASB/duf9W4fjA3Ro+GP
# +bC54n2OYiM3Cq8z92ejx8F5HWO2e4/75eC95B9tMw/eq7109GDyW2+2ut5Bn0o8
# MXJTERkyystWbqcnUkomA97qfiDCwvpWuOMdfTOksXe9S+xDxOJaCbiyTivgPulx
# cT+tfQTRXBr5UwtPvc+INnafOEilzVHA11tv+pHcylryxBc08cUihf/ttqkZS0bm
# x3VxQay8uohaTp0//ECA9vYGW51SKdJMJhXvKyIdBLy8WYNchTt8yRITv9Adxg9J
# qGujh+TnP1bW3aQjFtlEl7SzgNeI8OVA/CJAWWUuRT3Au9ToOpHy/flrrknhN4he
# Ew7/6ILMv4HSsj0YZ4otAgMBAAGjggFQMIIBTDB1BggrBgEFBQcBAQRpMGcwOwYI
# KwYBBQUHMAKGL2h0dHA6Ly9zdWJjYS5yZXBvc2l0b3J5LmNlcnR1bS5wbC9jdHNj
# YTIwMjEuY2VyMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0dW0u
# Y29tMB8GA1UdIwQYMBaAFL5UAi+/QGxzQ86sCSVOnkNEGu7gMAwGA1UdEwEB/wQC
# MAAwOQYDVR0fBDIwMDAuoCygKoYoaHR0cDovL3N1YmNhLmNybC5jZXJ0dW0ucGwv
# Y3RzY2EyMDIxLmNybDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAOBgNVHQ8BAf8E
# BAMCB4AwIgYDVR0gBBswGTAIBgZngQwBBAIwDQYLKoRoAYb2dwIFAQswHQYDVR0O
# BBYEFCM5aiirmhKnpKfyH/lFmr+N/oIDMA0GCSqGSIb3DQEBDAUAA4ICAQBm/pOb
# rAveTqYFPCSthxNVwBHTW4TfuBQY2KGCdMlH6ALjVPYf2XARNGMTXScR0JQ2/c2L
# xLpoHHhRwxFf3d933DhFS165rYj6iPSQKm41rc9AMpTCjMdmai1aK9o870pOzTzL
# RhWFVIxm+5Qf+t4V5z14tPoUsAWdfRKD1Zzvzfe5AHlE9rxOZcGROiULxs3Edf7Z
# J8RZI0jzD9juj2oEX2XS7BVKuNCK1W0h1mjapv028yqo8UtZh5/1Ib48Yyp88BDY
# Shh5PSGUUuS5tYXVfcduIPBe3PdNTYRYdT5VNxkwLgjMzIcr/6SH2fejFJDjnCxj
# vXLuarErJg/IdI+hXdGIBDVOGv1zrkF4ktoQvFZIp5DIAYh0GwyUhc87qqn5UaYT
# j2+iyiBDSNh7aGCrkuAnqg7Qh7frhX+QhMGeJ9SpLpnG76qN85hLyjvYR6gRYiLT
# j8G4fxjqV400/buPw0rBo9yJZyq/8cGdCOLPOYf9qpefTcrZKeO9pg+UJHx8UABb
# aTqizRxm8sUSEycccCCFhIit3AI/3wGuSOADeiA676NcQ7+dsP04YvzvdN7pTZNy
# g672WpBDJdg0eItHl4yomvNeIOWh1qs4QqOW6TI8Ocsi4uSDTLorxLUDnBTwKQzz
# pxd1YUctEgRh32R0J9FkFkeEruJU94RljFr1uDCCBrkwggShoAMCAQICEQCZo4AK
# JlU7ZavcboSms+o5MA0GCSqGSIb3DQEBDAUAMIGAMQswCQYDVQQGEwJQTDEiMCAG
# A1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVt
# IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3Rl
# ZCBOZXR3b3JrIENBIDIwHhcNMjEwNTE5MDUzMjE4WhcNMzYwNTE4MDUzMjE4WjBW
# MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEu
# MSQwIgYDVQQDExtDZXJ0dW0gQ29kZSBTaWduaW5nIDIwMjEgQ0EwggIiMA0GCSqG
# SIb3DQEBAQUAA4ICDwAwggIKAoICAQCdI88EMCM7wUYs5zNzPmNdenW6vlxNur3r
# Lfi+5OZ+U3iZIB+AspO+CC/bj+taJUbMbFP1gQBJUzDUCPx7BNLgid1TyztVLn52
# NKgxxu8gpyTr6EjWyGzKU/gnIu+bHAse1LCitX3CaOE13rbuHbtrxF2tPU8f253Q
# gX6eO8yTbGps1Mg+yda3DcTsOYOhSYNCJiL+5wnjZ9weoGRtvFgMHtJg6i671OPX
# IciiHO4Lwo2p9xh/tnj+JmCQEn5QU0NxzrOiRna4kjFaA9ZcwSaG7WAxeC/xoZSx
# F1oK1UPZtKVt+yrsGKqWONoK6f5EmBOAVEK2y4ATDSkb34UD7JA32f+Rm0wsr5aj
# zftDhA5mBipVZDjHpwzv8bTKzCDUSUuUmPo1govD0RwFcTtMXcfJtm1i+P2UNXad
# PyYVKRxKQATHN3imsfBiNRdN5kiVVeqP55piqgxOkyt+HkwIA4gbmSc3hD8ke66t
# 9MjlcNg73rZZlrLHsAIV/nJ0mmgSjBI/TthoGJDydekOQ2tQD2Dup/+sKQptalDl
# ui59SerVSJg8gAeV7N/ia4mrGoiez+SqV3olVfxyLFt3o/OQOnBmjhKUANoKLYlK
# mUpKEFI0PfoT8Q1W/y6s9LTI6ekbi0igEbFUIBE8KDUGfIwnisEkBw5KcBZ3XwnH
# mfznwlKo8QIDAQABo4IBVTCCAVEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
# 3XRdTADbe5+gdMqxbvc8wDLAcM0wHwYDVR0jBBgwFoAUtqFUOQLDoD+Oirz61Pgc
# ptE6Dv0wDgYDVR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDAGA1Ud
# HwQpMCcwJaAjoCGGH2h0dHA6Ly9jcmwuY2VydHVtLnBsL2N0bmNhMi5jcmwwbAYI
# KwYBBQUHAQEEYDBeMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0
# dW0uY29tMDIGCCsGAQUFBzAChiZodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0ucGwv
# Y3RuY2EyLmNlcjA5BgNVHSAEMjAwMC4GBFUdIAAwJjAkBggrBgEFBQcCARYYaHR0
# cDovL3d3dy5jZXJ0dW0ucGwvQ1BTMA0GCSqGSIb3DQEBDAUAA4ICAQB1iFgP5Y9Q
# KJpTnxDsQ/z0O23JmoZifZdEOEmQvo/79PQg9nLF/GJe6ZiUBEyDBHMtFRK0mXj3
# Qv3gL0sYXe+PPMfwmreJHvgFGWQ7XwnfMh2YIpBrkvJnjwh8gIlNlUl4KENTK5DL
# qsYPEtRQCw7R6p4s2EtWyDDr/M58iY2UBEqfUU/ujR9NuPyKk0bEcEi62JGxauFY
# zZ/yld13fHaZskIoq2XazjaD0pQkcQiIueL0HKiohS6XgZuUtCKA7S6CHttZEsOb
# QJ1j2s0urIDdqF7xaXFVaTHKtAuMfwi0jXtF3JJphrJfc+FFILgCbX/uYBPBlbBI
# P4Ht4xxk2GmfzMn7oxPITpigQFJFWuzTMUUgdRHTxaTSKRJ/6Uh7ki/pFjf9sUAS
# WgxT69QF9Ki4JF5nBIujxZ2sOU9e1HSCJwOfK07t5nnzbs1LbHuAIGJsRJiQ6HX/
# DW1XFOlXY1rc9HufFhWU+7Uk+hFkJsfzqBz3pRO+5aI6u5abI4Qws4YaeJH7H7M8
# X/YNoaArZbV4Ql+jarKsE0+8XvC4DJB+IVcvC9Ydqahi09mjQse4fxfef0L7E3hh
# o2O3bLDM6v60rIRUCi2fJT2/IRU5ohgyTch4GuYWefSBsp5NPJh4QRTP9DC3gc5Q
# EKtbrTY0Ka87Web7/zScvLmvQBm8JDFpDjCCBrkwggShoAMCAQICEQDn/2nHOzXO
# S5Em2HR8aKWHMA0GCSqGSIb3DQEBDAUAMIGAMQswCQYDVQQGEwJQTDEiMCAGA1UE
# ChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENl
# cnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBO
# ZXR3b3JrIENBIDIwHhcNMjEwNTE5MDUzMjA3WhcNMzYwNTE4MDUzMjA3WjBWMQsw
# CQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQw
# IgYDVQQDExtDZXJ0dW0gVGltZXN0YW1waW5nIDIwMjEgQ0EwggIiMA0GCSqGSIb3
# DQEBAQUAA4ICDwAwggIKAoICAQDpEh8ENe25XXrFppVBvoplf0530W0lddNmjtv4
# YSh/f7eDQKFaIqc7tHj7ox+u8vIsJZlroakUeMS3i3T8aJRC+eQs4FF0GqvkM6+W
# ZO8kmzZfxmZaBYmMLs8FktgFYCzywmXeQ1fEExflee2OpbHVk665eXRHjH7MYZIz
# Nnjl2m8Hy8ulB9mR8wL/W0v0pjKNT6G0sfrx1kk+3OGosFUb7yWNnVkWKU4qSxLv
# 16kJ6oVJ4BSbZ4xMak6JLeB8szrK9vwGDpvGDnKCUMYL3NuviwH1x4gZG0JAXU3x
# 2pOAz91JWKJSAmRy/l0s0l5bEYKolg+DMqVhlOANd8Yh5mkQWaMEvBRE/kAGzIqg
# WhwzN2OsKIVtO8mf5sPWSrvyplSABAYa13rMYnzwfg08nljZHghquCJYCa/xHK9a
# cev9UD7Y+usr15d7mrszzxhF1JOr1Mpup2chNSBlyOObhlSO16rwrffVrg/SzaKf
# SndS5swRhr8bnDqNJY9TNyEYvBYpgF95K7p0g4LguR4A++Z1nFIHWVY5v0fNVZmg
# zxD9uVo/gta3onGOQj3JCxgYx0KrCXu4yc9QiVwTFLWbNdHFSjBCt5/8Q9pLuRhV
# ocdCunhcHudMS1CGQ/Rn0+7P+fzMgWdRKfEOh/hjLrnQ8BdJiYrZNxvIOhM2aa3z
# EDHNwwIDAQABo4IBVTCCAVEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUvlQC
# L79AbHNDzqwJJU6eQ0Qa7uAwHwYDVR0jBBgwFoAUtqFUOQLDoD+Oirz61PgcptE6
# Dv0wDgYDVR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMDAGA1UdHwQp
# MCcwJaAjoCGGH2h0dHA6Ly9jcmwuY2VydHVtLnBsL2N0bmNhMi5jcmwwbAYIKwYB
# BQUHAQEEYDBeMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0dW0u
# Y29tMDIGCCsGAQUFBzAChiZodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0ucGwvY3Ru
# Y2EyLmNlcjA5BgNVHSAEMjAwMC4GBFUdIAAwJjAkBggrBgEFBQcCARYYaHR0cDov
# L3d3dy5jZXJ0dW0ucGwvQ1BTMA0GCSqGSIb3DQEBDAUAA4ICAQC4k1l3yUwV/ZQH
# CKCneqAs8EGTnwEUJLdDpokN/dMhKjK0rR5qX8nIIHzxpQR3TAw2IRw1Uxsr2Pli
# G3bCFqSdQTUbfaTq6V3vBzEebDru9QFjqlKnxCF2h1jhLNFFplbPJiW+JSnJTh1f
# KEqEdKdxgl9rVTvlxfEJ7exOn25MGbd/wGPwuSmMxRJVO0wnqgS7kmoJjNF9zqee
# hFSDDP8ZVkWg4EZ2tIS0M3uZmByRr+1Lkwjjt8AtW83mVnZTyTsOb+FNfwJY7DS4
# FmWhkRbgcHRetreoTirPOr/ozyDKhT8MTSTf6Lttg6s6T/u08mDWw6HK04ZRDfQ9
# sb77QV8mKgO44WGP31vXnVKoWVJpFBjPvjL8/Zck/5wXX2iqjOaLStFOR/IQki+E
# hn4zlcgVm22ZVCBPF+l8nAwUUShCtKuSU7GmZLKCmmxQMkSiWILTm8EtVD6AxnJh
# oq8EnhjEEyUoflkeRF2WhFiVQOmWTwZRr44IxWGkNJC6tTorW5rl2Zl+2e9JLPYf
# 3pStAPMDoPKIjVXd6NW2+fZrNUBeDo2eOa5Fn7Brs/HLQff5Xgris5MeUbdVgDrF
# 8uxO6cLPvZPo63j62SsNg55pTWk9fUIF9iPoRbb4QurjoY/woI1RAOKtYtTic6aA
# Jq3u83RIPpGXBSJKwx4KJAOZnCDCtTGCBqIwggaeAgEBMGowVjELMAkGA1UEBhMC
# UEwxITAfBgNVBAoTGEFzc2VjbyBEYXRhIFN5c3RlbXMgUy5BLjEkMCIGA1UEAxMb
# Q2VydHVtIENvZGUgU2lnbmluZyAyMDIxIENBAhBX5gfP8LabvnxCXoItEBa/MA0G
# CWCGSAFlAwQCAQUAoIGEMBgGCisGAQQBgjcCAQwxCjAIoAKAAKECgAAwGQYJKoZI
# hvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcC
# ARUwLwYJKoZIhvcNAQkEMSIEIHbF3wy8NobaPelDfdZ614J9HZOpGkFt++MwhVUl
# HdqLMA0GCSqGSIb3DQEBAQUABIIBgGEsHpvkswU/XYk5isyspKYFBzvZn21eJPVC
# YDDvfbYyez/q5njm5fteRq68w/DTGDltLOkpcGJ9wXq13lPN9U4/bAZfg4jlTN5v
# b/DDSgkF4RKchNTMBeiRhKQqRCc5U7IrE4u1Xfj/iBafYbWWB4ry2PusP5Z9Fw89
# NUC99HPxcSUkA9xJGhLBptuK8Dwzu7BrcqTjEx9vLxdGbwPMQDJEKlALfJ73ROCY
# 785YdPQxQfhZZLqFjcJlDS//DQdoEn3y7pIVKg0pOwbv1mztogvFgAY3DPeT+Z5M
# QD/NJO5KbyUZSd82tI2IHIFFBNh0S5fcSj+pA1oUBIc+IRuyVTGJ7sEhKZuV4BLp
# iBVsRCAo6xXkAXE64jGikW4rJnqRpIMuiNHKQXcKUUokzC141V8IIhhb4vMyzed5
# tIhaArx1GzFbwTBulPN8GAmRBqFod3YoqKUU1fFhM47QWryyiLwaFJEHjoYZPkVj
# Vol26kv7qWro1LXiTPVUOalX4TFqpqGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTQxNDBaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDCTTnU2PprM1/ZwAMc5xI/6T6o6K+TkKQga
# uwZHedRqI9wm2Qz+0BnpfQ4//1qTH2UwgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAlnJEgb69bM/eq/cfkNAevAGLlqIL4GUac99EtPu11wNGiT/I
# o1aOXbDnmzPN6jj+T7SisN24+lYjDOH4fLfQY/XAlgyN00YD9vAsgNCl3SaPPZIf
# j/fW9nkM5sX3/LoFGX3VelRezpQJhtuQG8FuftNzJbHFXy3AGBJpayEUOxa7wCoc
# C7SNbi/4GofwFluSsOJxZ3usfZxvGFI7NbubeGjN4z680PstU51dFmBsukGkFFpl
# P/RoiAxFfv0uUfXgi6GCxGS8iB+VVni59EXZyb3rX0mERzqtuBRsgtKa+Y+4oVVq
# 9Dyw/4MZmV9OIyE1vtJns5mY6LpUMsdv93kVrvO5QT5vvoslAurgfjMy8UJjjD2H
# wkVG8XL8KahojmJPlIvPYN7oYar7kaU6SmauWGVTWoVLJ/RdBUsYtdkL5M6wQhJw
# EPBogFl30gbb7qXDFK+VpCJVKvE3iR68mGQ8X16uMOXMjd5CAePemar6GW1A6QyG
# 75RKyuupZzJaA8m515hNDFaFcccVeExsRhSXf+FgiANcIQhNb2SxaY3nCml8aLir
# fCfodYIIkMCM3BEoq2TY504d8kFqeBJu+VuhVIgKO4wR+wrljlESyx7b4Q/UWy2q
# p3aDxjsL2Pu3OWlb68HxhQMQ7fELBSPrCSNO31MXYGovNYtD+KDs8D8f2oM=
# SIG # End signature block