Public/Reportings/Update-EntraOpsClassificationExplorerData.ps1

#Requires -Version 7.0
function Update-EntraOpsClassificationExplorerData {

    <#
    .SYNOPSIS
        Refreshes the embedded classification data for the Classification Explorer static web
        app, for either the standalone (AzurePrivilegedIAM) or the EntraOps-embedded deployment.

    .DESCRIPTION
        This is the single, canonical generator for the Classification Explorer app source that
        lives (byte-identical, apart from js/mode.js) in two repositories:

            * AzurePrivilegedIAM/ClassificationExplorer (standalone deployment)
            * <EntraOps repo>/Reports/ClassificationExplorer (entraops deployment)

        -Mode selects which deployment is being refreshed:

            Standalone (default) - reads classification-logic templates from the flat
                EntraOps_Classification/ folder of -RepoRoot. No tenant-specific classification
                variants. Also (re)generates the git-log-based change history for the History
                view and notifications (skip with -SkipHistory).

            EntraOps - reads the RBAC system outputs (classified roles) from the AzurePrivilegedIAM
                repository (-RepoRoot, auto-detected as a sibling folder when omitted) and the
                classification-logic templates from Classification/Templates of the EntraOps
                repository (-EntraOpsRoot), plus any tenant-specific parameterized copies written
                by Update-EntraOpsClassificationControlPlaneScope to Classification/<TenantName>/
                (embedded as window.EOCE_TENANTS for the variant picker / Template Comparison
                view). Missing/optional template files are embedded as an empty array with a
                warning instead of failing generation. Generates change history for notifications;
                the full History view remains hidden in this mode.

        The web app is fully self-contained in both modes: it loads its data exclusively from the
        embedded bundle data/classification-data.js (window.EOCE_DATA) - there is no runtime fetch
        fallback, so the app works from the file:// protocol with no web server. Regenerates:

            * data/classification-data.js - the embedded bundle (window.EOCE_DATA), plus
                                              window.EOCE_TENANTS (entraops mode only).
            * data-manifest.json - timestamp, size, SHA-256 hash and item count per
                                              source file (traceability only - never read by the
                                              app itself).
            * data/attack-paths.js - attack-path catalog, from content/attack-paths/*.md.
            * data/tier-map.js - Overview (Enterprise Access Model Map) Sankey dataset.
            * data/notification-data.js - compact latest-change summary for the app bar.
            * data/history-data.js - change history (git log) for notifications and the
                                              standalone History view.

        Each file is validated (the *.Param.json files contain EntraOps scope placeholder tokens
        such as <Tier0IncludedResourceScope>, which are sanitized the same way the app does before
        validation).

    .PARAMETER Mode
        'Standalone' (default) or 'EntraOps'. See DESCRIPTION.

    .PARAMETER RepoRoot
        Path to the AzurePrivilegedIAM repository root (contains 'Classification' and, in
        Standalone mode, 'EntraOps_Classification'). Defaults to the root inferred from the
        imported module in Standalone mode; auto-detected as a sibling folder of -EntraOpsRoot named
        'AzurePrivilegedIAM*' in EntraOps mode.

    .PARAMETER EntraOpsRoot
        EntraOps mode only. Path to the EntraOps repository root (contains 'Classification/Templates'
        and any 'Classification/<TenantName>' tenant-specific copies). Defaults to the parent of
        this script's folder.

    .PARAMETER AppRoot
        Path to the ClassificationExplorer app folder (where the generated content is written).
        Defaults to 'ClassificationExplorer' next to -RepoRoot (Standalone) or
        'Reports/ClassificationExplorer' under -EntraOpsRoot (EntraOps).

    .PARAMETER SkipManifest
        Do not write data-manifest.json.

    .PARAMETER SkipEmbed
        Do not write data/classification-data.js (and data/tier-map.js). By default the script
        generates this embedded bundle, which lets the app run with no web server.

    .PARAMETER SkipHistory
        Do not write data/history-data.js (the git-log-based notification and History dataset).
        Useful while iterating locally, since walking the commit history of every tracked file is
        the slowest part of this script.

    .PARAMETER StrictAttackPaths
        Fail generation when an attack-path action, role or API permission mapping is malformed or
        cannot be resolved against the embedded classification data. Without this switch, mapping
        issues are warnings so contributors can iterate before classifications are updated.

    .PARAMETER PassThru
        Emit the result objects (one per source file) to the pipeline.

    .EXAMPLE
        Update-EntraOpsClassificationExplorerData

        Refreshes the standalone app (AzurePrivilegedIAM/ClassificationExplorer), inferring
        -RepoRoot from this script's location.

    .EXAMPLE
        Update-EntraOpsClassificationExplorerData -Mode EntraOps -Verbose -WhatIf

        Shows what would be generated for the EntraOps-embedded app without changing any files.

    .NOTES
        Cross-platform on PowerShell 7+. This file is synced verbatim
        between the two repositories by Sync-EntraOpsClassificationExplorerSource (to
        <EntraOpsRoot>/EntraOps/Public/Reportings/, where it is loaded as a public function of the
        EntraOps module, same as every Export-EntraOps* cmdlet in this repository) - edit the
        canonical copy in AzurePrivilegedIAM/Modules/EntraOps.Classification/Public.
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [ValidateSet('Standalone', 'EntraOps')]
        [string]$Mode = 'Standalone',

        [string]$RepoRoot,
        [string]$EntraOpsRoot,
        [string]$AppRoot,

        [switch]$SkipManifest,
        [switch]$SkipEmbed,
        [switch]$SkipHistory,
        [switch]$StrictAttackPaths,
        [switch]$PassThru
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    function Resolve-FullPath {
        param([string]$Path)
        return [System.IO.Path]::GetFullPath($Path)
    }

    # Mirror the app's sanitizer: replace <Token> with "«param:Token»" so *.Param.json
    # files (which embed placeholder tokens) parse as valid JSON. Uses [char] codes so it
    # works on both Windows PowerShell 5.1 and PowerShell 7+.
    function ConvertTo-SanitizedJsonText {
        param([string]$Text)
        return [regex]::Replace($Text, '<([A-Za-z0-9_]+)>', {
                param($m)
                '"' + [char]0x00AB + 'param:' + $m.Groups[1].Value + [char]0x00BB + '"'
            })
    }

    function Get-Sha256 {
        param([string]$Path)
        return (Get-FileHash -Path $Path -Algorithm SHA256).Hash.ToLowerInvariant()
    }

    # Strict-mode safe property accessor for the parsed classification objects.
    function Get-JsonProp {
        param($Object, [string]$Name)
        if ($null -ne $Object -and ($Object.PSObject.Properties.Name -contains $Name)) { return $Object.$Name }
        return $null
    }

    $ValidTiers = @('ControlPlane', 'ManagementPlane', 'WorkloadPlane', 'UserAccess', 'Unclassified')

    function Get-TierName {
        param($Value)
        if ($Value -and ($ValidTiers -contains [string]$Value)) { return [string]$Value }
        return 'Unclassified'
    }

    # The role/permission catalogs used by notifications and the History view
    # (data/history-data.js) - kept in sync with EOCE.RBAC_SYSTEMS[*].file /
    # EOCE.PERMISSION_SETS[*].file in js/config.js.
    $HistorySources = [ordered]@{
        EntraID            = [ordered]@{ label = 'Microsoft Entra ID Directory Roles'; kind = 'roles'; file = 'Classification/Classification_EntraIdDirectoryRoles.json' }
        IdentityGovernance = [ordered]@{ label = 'Microsoft Entra Identity Governance'; kind = 'roles'; file = 'Classification/Classification_IdentityGovernance.json' }
        Azure              = [ordered]@{ label = 'Azure Resource Roles'; kind = 'roles'; file = 'Classification/Classification_AzureResources.json' }
        DeviceManagement   = [ordered]@{ label = 'Intune Device Management Roles'; kind = 'roles'; file = 'Classification/Classification_DeviceManagementRoles.json' }
        ApiPermissions     = [ordered]@{ label = 'API Permissions'; kind = 'permissions'; file = 'Classification/Classification_ApiPermissions.json' }
    }

    # Flattens a parsed classification array (at one point in git history) into an ordered
    # dictionary keyed by a stable item id, for cheap diffing between commits (History view).
    function ConvertTo-HistoryIndex {
        param($Parsed, [string]$Kind)

        $index = [ordered]@{}
        if ($null -eq $Parsed) { return $index }
        if ($Parsed -isnot [System.Array]) { $Parsed = @($Parsed) }

        $rolesById = @{}
        if ($Kind -ne 'permissions') {
            foreach ($role in $Parsed) {
                $roleId = [string](Get-JsonProp $role 'RoleId')
                if (-not [string]::IsNullOrEmpty($roleId)) { $rolesById[$roleId] = $role }
            }
        }

        function Add-InheritedActionKeys {
            param([string]$RoleId, [hashtable]$Seen, [hashtable]$ActionKeys)
            if ([string]::IsNullOrEmpty($RoleId) -or $Seen.ContainsKey($RoleId) -or -not $rolesById.ContainsKey($RoleId)) { return }
            $Seen[$RoleId] = $true

            $inheritedRole = $rolesById[$RoleId]
            $inheritedPermissions = Get-JsonProp $inheritedRole 'RolePermissions'
            if ($null -ne $inheritedPermissions) {
                if ($inheritedPermissions -isnot [System.Array]) { $inheritedPermissions = @($inheritedPermissions) }
                foreach ($permission in $inheritedPermissions) {
                    $action = [string](Get-JsonProp $permission 'AuthorizedResourceAction')
                    if ([string]::IsNullOrEmpty($action)) { continue }
                    $category = [string](Get-JsonProp $permission 'Category')
                    $ActionKeys[$action + '|' + $category] = $true
                }
            }

            $parents = Get-JsonProp $inheritedRole 'InheritsPermissionsFrom'
            if ($null -ne $parents) {
                if ($parents -isnot [System.Array]) { $parents = @($parents) }
                foreach ($parentId in $parents) { Add-InheritedActionKeys -RoleId ([string]$parentId) -Seen $Seen -ActionKeys $ActionKeys }
            }
        }

        foreach ($item in $Parsed) {
            if ($null -eq $item) { continue }
            if ($Kind -eq 'permissions') {
                $id = [string](Get-JsonProp $item 'PermissionId')
                if ([string]::IsNullOrEmpty($id)) { $id = [string](Get-JsonProp $item 'PermissionValue') + '|' + [string](Get-JsonProp $item 'TargetAppId') }
                if ([string]::IsNullOrEmpty($id)) { continue }
                $index[$id] = [ordered]@{
                    id       = $id
                    name     = [string](Get-JsonProp $item 'PermissionValue')
                    tier     = Get-TierName (Get-JsonProp $item 'EAMTierLevelName')
                    app      = [string](Get-JsonProp $item 'TargetAppDisplayName')
                    category = [string](Get-JsonProp $item 'Category')
                    type     = [string](Get-JsonProp $item 'PermissionType')
                }
            } else {
                $id = [string](Get-JsonProp $item 'RoleId')
                if ([string]::IsNullOrEmpty($id)) { $id = [string](Get-JsonProp $item 'RoleName') }
                if ([string]::IsNullOrEmpty($id)) { continue }
                $cls = Get-JsonProp $item 'Classification'
                $tier = 'Unclassified'
                if ($cls) { $tier = Get-TierName (Get-JsonProp $cls 'EAMTierLevelName') }
                $actions = [ordered]@{}
                $perms = Get-JsonProp $item 'RolePermissions'
                if ($null -ne $perms) {
                    if ($perms -isnot [System.Array]) { $perms = @($perms) }
                    foreach ($p in $perms) {
                        $act = [string](Get-JsonProp $p 'AuthorizedResourceAction')
                        if ([string]::IsNullOrEmpty($act)) { continue }
                        $cat = [string](Get-JsonProp $p 'Category')
                        $key = $act + '|' + $cat
                        $actions[$key] = [ordered]@{ action = $act; category = $cat; tier = Get-TierName (Get-JsonProp $p 'EAMTierLevelName') }
                    }
                }
                $inheritedActionKeys = @{}
                $parents = Get-JsonProp $item 'InheritsPermissionsFrom'
                if ($null -ne $parents) {
                    if ($parents -isnot [System.Array]) { $parents = @($parents) }
                    $seenParents = @{}
                    foreach ($parentId in $parents) { Add-InheritedActionKeys -RoleId ([string]$parentId) -Seen $seenParents -ActionKeys $inheritedActionKeys }
                }
                foreach ($key in $inheritedActionKeys.Keys) { $actions.Remove($key) }
                $index[$id] = [ordered]@{
                    id           = $id
                    name         = [string](Get-JsonProp $item 'RoleName')
                    tier         = $tier
                    isPrivileged = ((Get-JsonProp $item 'isPrivileged') -eq $true)
                    actionCount  = $actions.Count
                    actions      = $actions
                }
            }
        }
        return $index
    }

    # Compares two consecutive snapshots (ordered dictionaries from ConvertTo-HistoryIndex)
    # and returns the added / removed / changed items for that commit (History view).
    function Compare-HistoryIndex {
        param([System.Collections.IDictionary]$Previous, [System.Collections.IDictionary]$Current, [string]$Kind)

        $added = New-Object System.Collections.Generic.List[object]
        $removed = New-Object System.Collections.Generic.List[object]
        $changed = New-Object System.Collections.Generic.List[object]

        foreach ($id in $Current.Keys) {
            $curr = $Current[$id]
            if (-not $Previous.Contains($id)) {
                $addedEntry = [ordered]@{ id = $id; name = $curr.name; tier = $curr.tier }
                if ($Kind -ne 'permissions') {
                    $addedEntry.actions = @($curr.actions.Values | ForEach-Object { $_.action } | Sort-Object)
                } else {
                    $addedEntry.category = $curr.category
                }
                $added.Add($addedEntry) | Out-Null
                continue
            }
            $prev = $Previous[$id]
            if ($Kind -eq 'permissions') {
                if ($prev.tier -ne $curr.tier -or $prev.category -ne $curr.category) {
                    $changed.Add([ordered]@{
                            id          = $id
                            name        = $curr.name
                            oldTier     = $prev.tier
                            newTier     = $curr.tier
                            oldCategory = $prev.category
                            newCategory = $curr.category
                        }) | Out-Null
                }
            } else {
                $actAdded = @()
                $actRemoved = @()
                foreach ($ak in $curr.actions.Keys) { if (-not $prev.actions.Contains($ak)) { $actAdded += $curr.actions[$ak].action } }
                foreach ($ak in $prev.actions.Keys) { if (-not $curr.actions.Contains($ak)) { $actRemoved += $prev.actions[$ak].action } }
                if ($actAdded.Count -gt 0 -and $actRemoved.Count -gt 0) {
                    $common = @($actAdded | Where-Object { $actRemoved -contains $_ } | Select-Object -Unique)
                    if ($common.Count -gt 0) {
                        $actAdded = @($actAdded | Where-Object { $common -notcontains $_ })
                        $actRemoved = @($actRemoved | Where-Object { $common -notcontains $_ })
                    }
                }
                if ($prev.tier -ne $curr.tier -or $actAdded.Count -gt 0 -or $actRemoved.Count -gt 0) {
                    $changed.Add([ordered]@{
                            id             = $id
                            name           = $curr.name
                            oldTier        = $prev.tier
                            newTier        = $curr.tier
                            actionsAdded   = @($actAdded | Sort-Object)
                            actionsRemoved = @($actRemoved | Sort-Object)
                        }) | Out-Null
                }
            }
        }
        foreach ($id in $Previous.Keys) {
            if (-not $Current.Contains($id)) {
                $prev = $Previous[$id]
                $removedEntry = [ordered]@{ id = $id; name = $prev.name; tier = $prev.tier }
                if ($Kind -ne 'permissions') {
                    $removedEntry.actions = @($prev.actions.Values | ForEach-Object { $_.action } | Sort-Object)
                }
                $removed.Add($removedEntry) | Out-Null
            }
        }

        return [ordered]@{
            added   = @($added.ToArray() | Sort-Object { $_.name })
            removed = @($removed.ToArray() | Sort-Object { $_.name })
            changed = @($changed.ToArray() | Sort-Object { $_.name })
        }
    }

    # Build the flat path dataset that powers the Overview (Enterprise Access Model Map) Sankey:
    # role system -> role -> service -> role action -> tier level.
    function ConvertTo-TierMapPaths {
        param([System.Collections.IDictionary]$Embed)

        $sysByFile = [ordered]@{
            'Classification/Classification_EntraIdDirectoryRoles.json' = 'EntraID'
            'Classification/Classification_IdentityGovernance.json'    = 'IdentityGovernance'
            'Classification/Classification_AzureResources.json'        = 'Azure'
            'Classification/Classification_DeviceManagementRoles.json' = 'DeviceManagement'
        }
        $docsFile = 'Classification/Classification_EntraIdDirectoryRolesFromMsftDocs.json'
        # Use the function-level $ValidTiers (5 tiers incl. WorkloadPlane) - resolved from the
        # calling scope; do not shadow it with a local subset here.
        $paths = New-Object System.Collections.Generic.List[object]

        function Add-RolePaths {
            param($Role, [string]$Sys, [bool]$DocsOnly, [hashtable]$MismatchActions = $null, $List)
            $roleId = [string](Get-JsonProp $Role 'RoleId')
            if ([string]::IsNullOrEmpty($roleId)) { return }
            $cls = Get-JsonProp $Role 'Classification'
            $roleClass = 'Unclassified'
            if ($cls) { $cv = Get-JsonProp $cls 'EAMTierLevelName'; if ($cv -and ($validTiers -contains [string]$cv)) { $roleClass = [string]$cv } }
            $priv = ((Get-JsonProp $Role 'isPrivileged') -eq $true)
            $cats = [string](Get-JsonProp $Role 'Categories')
            $roleName = [string](Get-JsonProp $Role 'RoleName')
            $perms = Get-JsonProp $Role 'RolePermissions'
            if ($null -eq $perms) { return }
            if ($perms -isnot [System.Array]) { $perms = @($perms) }
            foreach ($p in $perms) {
                $action = Get-JsonProp $p 'AuthorizedResourceAction'
                if ([string]::IsNullOrEmpty([string]$action)) { continue }
                $actionType = if ((Get-JsonProp $p 'ActionType') -eq 'DataAction') { 'DataAction' } else { 'Action' }
                $tv = Get-JsonProp $p 'EAMTierLevelName'
                $tier = if ($tv -and ($validTiers -contains [string]$tv)) { [string]$tv } else { 'Unclassified' }
                $svc = [string](Get-JsonProp $p 'Category')
                if ([string]::IsNullOrEmpty($svc)) { $svc = '(uncategorized)' }
                $graphOnlyDiff = $false
                if ($null -ne $MismatchActions) {
                    $graphOnlyDiff = $MismatchActions.ContainsKey([string]$action.ToLowerInvariant())
                }
                $List.Add([ordered]@{
                        sys           = $Sys
                        roleId        = $roleId
                        role          = $roleName
                        priv          = $priv
                        roleClass     = $roleClass
                        cats          = $cats
                        service       = $svc
                        action        = [string]$action
                        actionType    = $actionType
                        tier          = $tier
                        docsOnly      = $DocsOnly
                        graphOnlyDiff = $graphOnlyDiff
                    }) | Out-Null
            }
        }

        $docsActionsByRole = @{}
        $docsRoleObjects = @{}
        if ($Embed.Contains($docsFile)) {
            foreach ($role in $Embed[$docsFile]) {
                $rid = [string](Get-JsonProp $role 'RoleId')
                if ([string]::IsNullOrEmpty($rid)) { continue }
                $acts = @{}
                $rp = Get-JsonProp $role 'RolePermissions'
                if ($null -ne $rp) {
                    if ($rp -isnot [System.Array]) { $rp = @($rp) }
                    foreach ($p in $rp) {
                        $a = [string](Get-JsonProp $p 'AuthorizedResourceAction')
                        if (-not [string]::IsNullOrEmpty($a)) { $acts[$a.ToLowerInvariant()] = $true }
                    }
                }
                $docsActionsByRole[$rid] = $acts
                $docsRoleObjects[$rid] = $role
            }
        }

        $graphActionsByRole = @{}
        foreach ($file in $sysByFile.Keys) {
            if (-not $Embed.Contains($file)) { continue }
            $sys = $sysByFile[$file]
            foreach ($role in $Embed[$file]) {
                $rid = [string](Get-JsonProp $role 'RoleId')
                $mismatchActions = $null
                if ($sys -eq 'EntraID' -and -not [string]::IsNullOrEmpty($rid)) {
                    $graphActs = @{}
                    $rp = Get-JsonProp $role 'RolePermissions'
                    if ($null -ne $rp) {
                        if ($rp -isnot [System.Array]) { $rp = @($rp) }
                        foreach ($p in $rp) {
                            $a = [string](Get-JsonProp $p 'AuthorizedResourceAction')
                            if (-not [string]::IsNullOrEmpty($a)) { $graphActs[$a.ToLowerInvariant()] = $true }
                        }
                    }
                    $graphActionsByRole[$rid] = $graphActs
                    if ($docsActionsByRole.ContainsKey($rid)) {
                        $docsActs = $docsActionsByRole[$rid]
                        $mismatchActions = @{}
                        foreach ($la in $graphActs.Keys) {
                            if (-not $docsActs.ContainsKey($la)) { $mismatchActions[$la] = $true }
                        }
                    }
                }
                Add-RolePaths -Role $role -Sys $sys -DocsOnly $false -MismatchActions $mismatchActions -List $paths
            }
        }

        foreach ($rid in $docsRoleObjects.Keys) {
            $role = $docsRoleObjects[$rid]
            $graphActs = if ($graphActionsByRole.ContainsKey($rid)) { $graphActionsByRole[$rid] } else { $null }
            $roleClass = 'Unclassified'
            $cls = Get-JsonProp $role 'Classification'
            if ($cls) { $cv = Get-JsonProp $cls 'EAMTierLevelName'; if ($cv -and ($validTiers -contains [string]$cv)) { $roleClass = [string]$cv } }
            $priv = ((Get-JsonProp $role 'isPrivileged') -eq $true)
            $cats = [string](Get-JsonProp $role 'Categories')
            $roleName = [string](Get-JsonProp $role 'RoleName')
            $perms = Get-JsonProp $role 'RolePermissions'
            if ($null -eq $perms) { continue }
            if ($perms -isnot [System.Array]) { $perms = @($perms) }
            foreach ($p in $perms) {
                $action = Get-JsonProp $p 'AuthorizedResourceAction'
                if ([string]::IsNullOrEmpty([string]$action)) { continue }
                if ($null -ne $graphActs -and $graphActs.ContainsKey([string]$action.ToLowerInvariant())) { continue }
                $tv = Get-JsonProp $p 'EAMTierLevelName'
                $tier = if ($tv -and ($validTiers -contains [string]$tv)) { [string]$tv } else { 'Unclassified' }
                $svc = [string](Get-JsonProp $p 'Category')
                if ([string]::IsNullOrEmpty($svc)) { $svc = '(uncategorized)' }
                $paths.Add([ordered]@{
                        sys           = 'EntraID'
                        roleId        = $rid
                        role          = $roleName
                        priv          = $priv
                        roleClass     = $roleClass
                        cats          = $cats
                        service       = $svc
                        action        = [string]$action
                        tier          = $tier
                        docsOnly      = $true
                        graphOnlyDiff = $false
                    }) | Out-Null
            }
        }

        return , $paths.ToArray()
    }

    function ConvertTo-TierMapRoles {
        param([System.Collections.IDictionary]$Embed)

        $sysByFile = [ordered]@{
            'Classification/Classification_EntraIdDirectoryRoles.json' = 'EntraID'
            'Classification/Classification_IdentityGovernance.json'    = 'IdentityGovernance'
            'Classification/Classification_AzureResources.json'        = 'Azure'
            'Classification/Classification_DeviceManagementRoles.json' = 'DeviceManagement'
        }
        # Use the function-level $ValidTiers (5 tiers incl. WorkloadPlane) - resolved from the
        # calling scope; do not shadow it with a local subset here.
        $roles = New-Object System.Collections.Generic.List[object]

        foreach ($file in $sysByFile.Keys) {
            if (-not $Embed.Contains($file)) { continue }
            foreach ($role in $Embed[$file]) {
                $roleId = [string](Get-JsonProp $role 'RoleId')
                if ([string]::IsNullOrEmpty($roleId)) { continue }
                $classification = Get-JsonProp $role 'Classification'
                $tierName = 'Unclassified'
                $service = ''
                if ($classification) {
                    $value = Get-JsonProp $classification 'EAMTierLevelName'
                    if ($value -and ($validTiers -contains [string]$value)) { $tierName = [string]$value }
                    $service = [string](Get-JsonProp $classification 'Service')
                }
                $roles.Add([ordered]@{
                        sys       = $sysByFile[$file]
                        roleId    = $roleId
                        role      = [string](Get-JsonProp $role 'RoleName')
                        roleClass = $tierName
                        service   = $service
                    }) | Out-Null
            }
        }

        return , $roles.ToArray()
    }

    # --- Mode-specific path resolution -------------------------------------------------------
    # Basenames of classification-logic definition/param templates are identical between the two
    # repositories, only the containing folder differs (kept in sync with EOCE.TEMPLATE_BASE in
    # js/config.js).
    $TemplateBase = if ($Mode -eq 'EntraOps') { 'Classification/Templates' } else { 'EntraOps_Classification' }

    if ($Mode -eq 'EntraOps') {
        # This function lives at <EntraOpsRoot>/EntraOps/Public/Reportings/Update-EntraOpsClassificationExplorerData.ps1
        # (synced verbatim from AzurePrivilegedIAM/Modules/EntraOps.Classification/Public by
        # Sync-EntraOpsClassificationExplorerSource) once
        # copied into the EntraOps repository, where it is dot-sourced and exported as a public function by
        # the module loader (EntraOps.psm1). Prefer the module's own ModuleBase (always populated once the
        # module is imported) over $PSScriptRoot, which can be empty depending on how this function was invoked.
        if ([string]::IsNullOrWhiteSpace($EntraOpsRoot)) {
            $ModuleRoot = $MyInvocation.MyCommand.Module.ModuleBase
            if ([string]::IsNullOrWhiteSpace($ModuleRoot) -and -not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
                $ModuleRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
            }
            if ([string]::IsNullOrWhiteSpace($ModuleRoot)) {
                throw "Unable to resolve the EntraOps repository location. Pass -EntraOpsRoot explicitly."
            }
            $EntraOpsRoot = Split-Path -Parent $ModuleRoot
        }
        $EntraOpsRoot = Resolve-FullPath $EntraOpsRoot
        if ([string]::IsNullOrWhiteSpace($AppRoot)) { $AppRoot = Join-Path $EntraOpsRoot 'Reports/ClassificationExplorer' }

        if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
            $SentinelFile = 'Classification/Classification_EntraIdDirectoryRoles.json'
            $Candidates = @((Split-Path -Parent $AppRoot), $EntraOpsRoot)
            $CodingRoot = Split-Path -Parent $EntraOpsRoot
            if ($CodingRoot -and (Test-Path -LiteralPath $CodingRoot -PathType Container)) {
                $Candidates += @(Get-ChildItem -LiteralPath $CodingRoot -Directory -Filter 'AzurePrivilegedIAM*' | ForEach-Object { $_.FullName })
            }
            $RepoRoot = $Candidates | Where-Object { Test-Path -LiteralPath (Join-Path $_ $SentinelFile) -PathType Leaf } | Select-Object -First 1
            if (-not $RepoRoot) {
                throw "Could not auto-detect the AzurePrivilegedIAM repository (looked for '$SentinelFile' in: $($Candidates -join ', ')). Pass -RepoRoot pointing at your AzurePrivilegedIAM clone."
            }
            Write-Verbose "Auto-detected RepoRoot: $RepoRoot"
        }
    } else {
        if ([string]::IsNullOrWhiteSpace($RepoRoot)) {
            $module = $MyInvocation.MyCommand.Module
            if (-not $module -or -not $module.ModuleBase) {
                throw 'Update-EntraOpsClassificationExplorerData must be invoked from an imported module or supplied -RepoRoot.'
            }
            $RepoRoot = Resolve-EntraOpsClassificationRepoRoot -ModuleBase $module.ModuleBase
        }
        if ([string]::IsNullOrWhiteSpace($AppRoot)) { $AppRoot = Join-Path (Resolve-FullPath $RepoRoot) 'ClassificationExplorer' }
    }

    $RepoRoot = Resolve-FullPath $RepoRoot
    $AppRoot = Resolve-FullPath $AppRoot

    Write-Verbose "Mode : $Mode"
    Write-Verbose "Repository root : $RepoRoot"
    if ($Mode -eq 'EntraOps') { Write-Verbose "EntraOps root : $EntraOpsRoot" }
    Write-Verbose "App folder : $AppRoot"

    if (-not (Test-Path -LiteralPath $RepoRoot -PathType Container)) {
        throw "Repository root not found: $RepoRoot"
    }
    if ($Mode -eq 'EntraOps' -and -not (Test-Path -LiteralPath (Join-Path $EntraOpsRoot 'Classification/Templates') -PathType Container)) {
        throw "EntraOps classification templates not found: $(Join-Path $EntraOpsRoot 'Classification/Templates'). Pass -EntraOpsRoot pointing at the EntraOps repository root."
    }
    if (-not (Test-Path -LiteralPath $AppRoot -PathType Container)) {
        throw "App folder not found: $AppRoot"
    }
    if ((Resolve-FullPath $RepoRoot) -eq (Resolve-FullPath $AppRoot)) {
        throw "RepoRoot and AppRoot must differ (the app folder is the output target, not the source)."
    }

    # mode.js controls the app's interpretation of the embedded source paths. Refuse to
    # generate a bundle for a different deployment mode, which would otherwise hide
    # tenant features or select the wrong classification-template base at runtime.
    $modePath = Join-Path $AppRoot 'js/mode.js'
    if (-not (Test-Path -LiteralPath $modePath -PathType Leaf)) {
        throw "Deployment mode file not found: $modePath"
    }
    $modeText = Get-Content -LiteralPath $modePath -Raw -Encoding UTF8
    $modeMatch = [regex]::Match($modeText, "(?im)^\s*window\.EOCE_MODE\s*=\s*'(?<mode>standalone|entraops)'\s*;")
    if (-not $modeMatch.Success) {
        throw "Unable to read window.EOCE_MODE from $modePath. It must set 'standalone' or 'entraops'."
    }
    $appMode = $modeMatch.Groups['mode'].Value.ToLowerInvariant()
    $expectedAppMode = $Mode.ToLowerInvariant()
    if ($appMode -ne $expectedAppMode) {
        throw "Deployment mode mismatch: -Mode $Mode targets '$expectedAppMode', but $modePath declares '$appMode'. Update js/mode.js or use the matching -Mode."
    }

    $indexPath = Join-Path $AppRoot 'index.html'
    if (-not (Test-Path -LiteralPath $indexPath -PathType Leaf)) {
        throw "Classification Explorer index file not found: $indexPath"
    }
    $indexText = Get-Content -LiteralPath $indexPath -Raw -Encoding UTF8
    $themePath = if ($Mode -eq 'Standalone') { './theme/' } else { '../shared/' }
    $updatedIndexText = if ($Mode -eq 'Standalone') {
        $indexText.Replace('../shared/', $themePath)
    } else {
        $indexText.Replace('./theme/', $themePath)
    }
    if ($updatedIndexText -ne $indexText -and $PSCmdlet.ShouldProcess($indexPath, "Update $Mode theme asset paths")) {
        Set-Content -LiteralPath $indexPath -Value $updatedIndexText -Encoding UTF8
    }

    # --- Required / optional source files (relative paths, kept in sync with js/config.js) ----
    $RequiredFiles = @(
        # RBAC system outputs (classified roles + role actions) - always from -RepoRoot
        'Classification/Classification_EntraIdDirectoryRoles.json',
        'Classification/Classification_EntraIdDirectoryRolesFromMsftDocs.json',
        'Classification/Classification_IdentityGovernance.json',
        'Classification/Classification_AzureResources.json',
        'Classification/Classification_DeviceManagementRoles.json',
        # Permission catalogs - always from -RepoRoot
        'Classification/Classification_ApiPermissions.json',
        'Classification/Classification_AppRoles.json',
        'Classification/Classification_Scopes.json',
        # Classification-logic definitions and scope-aware parameter files - from $TemplateBase
        # (EntraOps_Classification/ in Standalone mode, Classification/Templates/ in EntraOps mode,
        # the latter from -EntraOpsRoot)
        "$TemplateBase/Classification_AadResources.json",
        "$TemplateBase/Classification_AadResources.Param.json",
        "$TemplateBase/Classification_IdentityGovernance.json",
        "$TemplateBase/Classification_IdentityGovernance.Param.json",
        "$TemplateBase/Classification_Azure.json",
        "$TemplateBase/Classification_Azure.Param.json",
        "$TemplateBase/Classification_DeviceManagement.json",
        "$TemplateBase/Classification_DeviceManagement.Param.json",
        "$TemplateBase/Classification_Defender.json",
        "$TemplateBase/Classification_Defender.Param.json"
    )

    # Optional in EntraOps mode only: embedded when present, otherwise embedded as an empty array
    # instead of failing generation (partial multi-source availability is expected there).
    # Required (must exist) in Standalone mode, matching this app's original single-source behavior.
    $OptionalFiles = @()
    if ($Mode -eq 'EntraOps') {
        $OptionalFiles = @(
            "$TemplateBase/Classification_RoleDefinitionOverwrites.json",
            # Built-in role action / API permission overwrites templates + API permission catalog:
            # consumed by the Customize Overwrites view (action/permission picker / load-template source).
            "$TemplateBase/Classification_RoleActionOverwrites.json",
            "$TemplateBase/Classification_ApiPermissionOverwrites.json",
            "$TemplateBase/Classification_ApiPermissions.json"
        )
    } else {
        $RequiredFiles += "$TemplateBase/Classification_RoleDefinitionOverwrites.json"
    }

    # --- Discover tenant-specific classification folders (EntraOps mode only) -----------------
    # Update-EntraOpsClassificationControlPlaneScope writes parameterized, tenant-specific copies
    # of the classification-logic files to Classification/<TenantName>/. Every such folder (any
    # subfolder of Classification other than Templates that contains classification or reasoning
    # JSON files) is embedded. Classification files remain separate from reasoning artifacts so
    # Template Comparison only receives files with a built-in template counterpart.
    $TenantFileSet = [System.Collections.Generic.HashSet[string]]::new()
    $TenantReasoningFileSet = [System.Collections.Generic.HashSet[string]]::new()
    $Tenants = @()
    if ($Mode -eq 'EntraOps') {
        $EntraOpsClassificationRoot = Join-Path $EntraOpsRoot 'Classification'
        foreach ($TenantDir in @(Get-ChildItem -LiteralPath $EntraOpsClassificationRoot -Directory | Where-Object { $_.Name -ne 'Templates' } | Sort-Object Name)) {
            $TenantJsonFiles = @(Get-ChildItem -LiteralPath $TenantDir.FullName -Filter 'Classification_*.json' -File | Sort-Object Name)
            $TenantReasoningFiles = @(Get-ChildItem -LiteralPath $TenantDir.FullName -File | Where-Object { $_.Name -like 'ScopeReasoning_*.json' -or $_.Name -eq 'DeviceManagement_ScopeGroupDeviceMembers.json' } | Sort-Object Name)
            if ($TenantJsonFiles.Count -eq 0 -and $TenantReasoningFiles.Count -eq 0) { continue }
            $TenantRelPaths = @($TenantJsonFiles | ForEach-Object { "Classification/$($TenantDir.Name)/$($_.Name)" })
            $TenantReasoningRelPaths = @($TenantReasoningFiles | ForEach-Object { "Classification/$($TenantDir.Name)/$($_.Name)" })
            foreach ($TenantRelPath in $TenantRelPaths) { [void]$TenantFileSet.Add($TenantRelPath) }
            foreach ($TenantReasoningRelPath in $TenantReasoningRelPaths) { [void]$TenantReasoningFileSet.Add($TenantReasoningRelPath) }
            $Tenants += [pscustomobject]@{ name = $TenantDir.Name; files = @($TenantRelPaths); reasoningFiles = @($TenantReasoningRelPaths) }
            Write-Verbose ("Tenant variant : {0} ({1} classification file(s), {2} reasoning file(s))" -f $TenantDir.Name, $TenantRelPaths.Count, $TenantReasoningRelPaths.Count)
        }
    }

    function Get-SourcePath {
        param([string]$Rel)
        if ($Mode -eq 'EntraOps' -and ($Rel -like "$TemplateBase/*" -or $TenantFileSet.Contains($Rel) -or $TenantReasoningFileSet.Contains($Rel))) {
            return Join-Path $EntraOpsRoot $Rel
        }
        return Join-Path $RepoRoot $Rel
    }

    # --- Pre-flight: check which required source files exist ---------------------------------
    $missing = @()
    foreach ($rel in $RequiredFiles) {
        $src = Get-SourcePath -Rel $rel
        if (-not (Test-Path -LiteralPath $src -PathType Leaf)) { $missing += $rel }
    }
    if ($Mode -eq 'EntraOps') {
        # A missing file usually means an RBAC system hasn't been classified/exported yet rather
        # than a broken environment, so this only warns and embeds an empty array (below) - unless
        # *none* of the required files are found, which reliably indicates a wrong -RepoRoot /
        # -EntraOpsRoot rather than a handful of not-yet-available files.
        if ($missing.Count -eq $RequiredFiles.Count) {
            throw "None of the required classification source files were found under RepoRoot '$RepoRoot' / EntraOpsRoot '$EntraOpsRoot'. Pass -RepoRoot pointing at your AzurePrivilegedIAM clone if the auto-detected path is wrong."
        }
        if ($missing.Count -gt 0) {
            Write-Warning ("Missing source file(s) under RepoRoot '$RepoRoot' / EntraOpsRoot '$EntraOpsRoot' - these will be embedded as empty data so generation can continue (affected views may show no data for the corresponding RBAC system):`n - " + ($missing -join "`n - "))
        }
    } elseif ($missing.Count -gt 0) {
        throw "Missing source file(s) under '$RepoRoot':`n - " + ($missing -join "`n - ")
    }

    $results = New-Object System.Collections.Generic.List[object]
    $errors = 0
    $embed = [ordered]@{}

    $AllSourceFiles = @($RequiredFiles) + @($TenantFileSet | Sort-Object) + @($TenantReasoningFileSet | Sort-Object)
    foreach ($rel in $AllSourceFiles) {
        $src = Get-SourcePath -Rel $rel
        $isParam = $rel -match '\.Param\.json$'

        if ($Mode -eq 'EntraOps' -and -not (Test-Path -LiteralPath $src -PathType Leaf)) {
            $embed[($rel -replace '\\', '/')] = @()
            continue
        }

        $itemCount = $null
        try {
            $raw = Get-Content -LiteralPath $src -Raw -Encoding UTF8
            $jsonText = if ($isParam) { ConvertTo-SanitizedJsonText $raw } else { $raw }
            # PowerShell's ConvertFrom-Json collapses a JSON '[]' to $null and a single-element
            # JSON array to a bare object (not an array). @() around the *entire* if/else
            # normalizes both back to a real array - every classification file here is a JSON
            # array and the app calls .forEach()/.map() on the embedded value.
            $parsedRaw = $jsonText | ConvertFrom-Json
            $parsed = @(if ($null -ne $parsedRaw) { $parsedRaw })
            if ($parsed -is [System.Array]) { $itemCount = $parsed.Count }
        } catch {
            Write-Error "Invalid JSON in '$rel': $($_.Exception.Message)"
            $errors++
            continue
        }

        $embed[($rel -replace '\\', '/')] = $parsed

        $bytes = (Get-Item -LiteralPath $src).Length
        $hash = Get-Sha256 -Path $src
        Write-Verbose ("Read {0} ({1:N0} bytes{2})" -f $rel, $bytes, $(if ($null -ne $itemCount) { ", $itemCount items" } else { '' }))

        $results.Add([pscustomobject]@{
                Path      = ($rel -replace '\\', '/')
                Bytes     = $bytes
                Items     = $itemCount
                IsParam   = [bool]$isParam
                Sha256    = $hash
                UpdatedAt = (Get-Date).ToUniversalTime().ToString('o')
            }) | Out-Null
    }

    # --- Optional source files (EntraOps mode): embed when present, embed an empty array otherwise
    foreach ($rel in $OptionalFiles) {
        $src = Get-SourcePath -Rel $rel
        $relKey = $rel -replace '\\', '/'
        if (-not (Test-Path -LiteralPath $src -PathType Leaf)) {
            Write-Verbose "Optional source file not found, embedding empty array: $rel"
            $embed[$relKey] = @()
            continue
        }

        $itemCount = $null
        try {
            $raw = Get-Content -LiteralPath $src -Raw -Encoding UTF8
            $parsedRaw = $raw | ConvertFrom-Json
            $parsed = @(if ($null -ne $parsedRaw) { $parsedRaw })
            if ($parsed -is [System.Array]) { $itemCount = $parsed.Count }
        } catch {
            Write-Error "Invalid JSON in '$rel': $($_.Exception.Message)"
            $errors++
            continue
        }

        $embed[$relKey] = $parsed
        $bytes = (Get-Item -LiteralPath $src).Length
        $hash = Get-Sha256 -Path $src
        Write-Verbose ("Read {0} ({1:N0} bytes{2})" -f $rel, $bytes, $(if ($null -ne $itemCount) { ", $itemCount items" } else { '' }))
        $results.Add([pscustomobject]@{
                Path      = $relKey
                Bytes     = $bytes
                Items     = $itemCount
                IsParam   = $false
                Sha256    = $hash
                UpdatedAt = (Get-Date).ToUniversalTime().ToString('o')
            }) | Out-Null
    }

    if ($errors -gt 0) {
        throw "$errors source file(s) failed JSON validation. No manifest written."
    }

    # --- Validate attack-path mappings against the embedded classification data ----------------
    # Attack-path markdown is intentionally descriptive, but Actions, Roles and Permissions are
    # machine-linked by the Explorer. Catalog structure is always strict; mapping resolution can
    # remain warning-only while contributors iterate, or fail a release run via -StrictAttackPaths.
    function Test-AttackPathCatalog {
        param([string]$Directory)

        if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { return }

        $files = @(Get-ChildItem -LiteralPath $Directory -Filter '*.md' | Sort-Object Name)
        $ids = @{}
        foreach ($file in $files) {
            $idLine = Get-Content -LiteralPath $file.FullName -Encoding UTF8 | Where-Object { $_ -match '^id:\s*(.+)$' } | Select-Object -First 1
            if (-not $idLine) { throw "Attack-path frontmatter is missing an id: $($file.Name)" }
            $id = [regex]::Match($idLine, '^id:\s*(.+)$').Groups[1].Value.Trim()
            if ($ids.ContainsKey($id)) {
                throw "Duplicate attack-path id '$id' in '$($ids[$id])' and '$($file.Name)'."
            }
            $ids[$id] = $file.Name
            if ($file.BaseName -ne $id) {
                throw "Attack-path id '$id' must match its filename '$($file.BaseName)' in '$($file.Name)'."
            }
        }

        $indexPath = Join-Path $Directory 'index.json'
        if (-not (Test-Path -LiteralPath $indexPath -PathType Leaf)) {
            throw "Attack-path catalog index is missing: $indexPath"
        }
        $indexText = Get-Content -LiteralPath $indexPath -Raw -Encoding UTF8
        if ([string]::IsNullOrWhiteSpace($indexText) -or $indexText.TrimStart()[0] -ne '[') {
            throw "Attack-path catalog index must be a JSON array: $indexPath"
        }
        try {
            $order = @($indexText | ConvertFrom-Json -ErrorAction Stop)
        } catch {
            throw "Invalid attack-path catalog index '$indexPath': $($_.Exception.Message)"
        }

        $duplicateIndexIds = @($order | Group-Object | Where-Object Count -gt 1 | ForEach-Object Name)
        $unknownIndexIds = @($order | Where-Object { -not $ids.ContainsKey([string]$_) })
        $unlistedFileIds = @($ids.Keys | Where-Object { $order -notcontains $_ } | Sort-Object)
        if ($duplicateIndexIds.Count -gt 0) {
            throw "Duplicate attack-path index id(s): $($duplicateIndexIds -join ', ')"
        }
        if ($unknownIndexIds.Count -gt 0) {
            throw "Attack-path index id(s) without matching Markdown files: $($unknownIndexIds -join ', ')"
        }
        if ($unlistedFileIds.Count -gt 0) {
            throw "Attack-path Markdown file(s) missing from index.json: $($unlistedFileIds -join ', ')"
        }
    }

    function Test-AttackPathMappings {
        param([string]$Directory, [hashtable]$Data)

        if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { return }

        $sourceFiles = [ordered]@{
            EntraID            = 'Classification/Classification_EntraIdDirectoryRoles.json'
            IdentityGovernance = 'Classification/Classification_IdentityGovernance.json'
            Azure              = 'Classification/Classification_AzureResources.json'
            DeviceManagement   = 'Classification/Classification_DeviceManagementRoles.json'
        }
        $roleNames = @{}
        $actions = @{}
        foreach ($system in $sourceFiles.Keys) {
            $items = @($Data[$sourceFiles[$system]])
            $roleNames[$system] = @($items | ForEach-Object { Get-JsonProp $_ 'RoleName' })
            $actions[$system] = @($items | ForEach-Object { Get-JsonProp $_ 'RolePermissions' } | ForEach-Object { Get-JsonProp $_ 'AuthorizedResourceAction' })
        }
        $permissions = @($Data['Classification/Classification_ApiPermissions.json'])
        $mappingErrors = New-Object System.Collections.Generic.List[string]

        foreach ($file in Get-ChildItem -LiteralPath $Directory -Filter '*.md') {
            $section = ''
            $lineNumber = 0
            foreach ($line in Get-Content -LiteralPath $file.FullName -Encoding UTF8) {
                $lineNumber++
                if ($line -match '^## (Actions|Roles|Permissions)$') { $section = $Matches[1]; continue }
                if ($line -match '^## ') { $section = ''; continue }
                if ([string]::IsNullOrEmpty($section) -or $line -notmatch '^[-*]\s+(.+)$') { continue }

                $parts = @($Matches[1].Split('|') | ForEach-Object { $_.Trim() })
                if ($section -eq 'Actions' -or $section -eq 'Roles') {
                    if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[0]) -or [string]::IsNullOrWhiteSpace($parts[1])) {
                        $mappingErrors.Add(("{0}:{1} [{2}] expected '<System> | <Value>': {3}" -f $file.Name, $lineNumber, $section, $Matches[1])) | Out-Null
                        continue
                    }
                    if (-not $sourceFiles.Contains($parts[0])) {
                        $mappingErrors.Add(("{0}:{1} [{2}] unknown system '{3}'" -f $file.Name, $lineNumber, $section, $parts[0])) | Out-Null
                        continue
                    }
                    $isKnown = if ($section -eq 'Actions') {
                        $knownActions = $actions[$parts[0]]
                        ($knownActions -contains $parts[1]) -or @($knownActions | Where-Object {
                                $_ -and $_.Contains('*') -and $parts[1] -like $_
                            }).Count -gt 0
                    } else {
                        $roleNames[$parts[0]] -contains $parts[1]
                    }
                    if (-not $isKnown) {
                        $mappingErrors.Add(("{0}:{1} [{2}] unresolved mapping: {3} | {4}" -f $file.Name, $lineNumber, $section, $parts[0], $parts[1])) | Out-Null
                    }
                    continue
                }
                if ($section -eq 'Permissions') {
                    if ($parts.Count -ne 3 -or @($parts | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) {
                        $mappingErrors.Add(("{0}:{1} [Permissions] expected '<ResourceApp> | <Permission> | <Type>': {2}" -f $file.Name, $lineNumber, $Matches[1])) | Out-Null
                        continue
                    }
                    $isKnown = @($permissions | Where-Object {
                            (Get-JsonProp $_ 'TargetAppDisplayName') -eq $parts[0] -and
                            (Get-JsonProp $_ 'PermissionValue') -eq $parts[1] -and
                            (Get-JsonProp $_ 'PermissionType') -eq $parts[2]
                        }).Count -gt 0
                    if (-not $isKnown) {
                        $mappingErrors.Add(("{0}:{1} [Permissions] unresolved mapping: {2} | {3} | {4}" -f $file.Name, $lineNumber, $parts[0], $parts[1], $parts[2])) | Out-Null
                    }
                }
            }
        }

        if ($mappingErrors.Count -gt 0) {
            if ($StrictAttackPaths) {
                throw "$($mappingErrors.Count) attack-path mapping error(s):`n - $($mappingErrors -join "`n - ")"
            }
            foreach ($mappingError in $mappingErrors) {
                Write-Warning "Attack-path mapping issue: $mappingError"
            }
        }
    }

    $attackPathDirectory = Join-Path $AppRoot 'content/attack-paths'
    Test-AttackPathCatalog -Directory $attackPathDirectory
    Test-AttackPathMappings -Directory $attackPathDirectory -Data $embed

    # --- Write the data manifest ---------------------------------------------------------------
    if (-not $SkipManifest) {
        $manifestPath = Join-Path $AppRoot 'data-manifest.json'
        $manifest = [ordered]@{
            generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
            mode         = $Mode
            repoRoot     = Split-Path -Leaf $RepoRoot
            fileCount    = $results.Count
            files        = @($results | ForEach-Object {
                    [ordered]@{ path = $_.Path; bytes = $_.Bytes; items = $_.Items; param = $_.IsParam; sha256 = $_.Sha256 }
                })
        }
        if ($PSCmdlet.ShouldProcess($manifestPath, 'Write data-manifest.json')) {
            Save-EntraOpsReportDataFile -Content ($manifest | ConvertTo-Json -Depth 5) -LiteralPath $manifestPath
            Write-Verbose "Wrote manifest: $manifestPath"
        }
    }

    # --- Write the embedded JS bundle (lets the app run with no web server, file://) -----------
    $embedBytes = 0
    if (-not $SkipEmbed) {
        $embedDir = Join-Path $AppRoot 'data'
        $embedPath = Join-Path $embedDir 'classification-data.js'
        if (-not (Test-Path -LiteralPath $embedDir)) {
            if ($PSCmdlet.ShouldProcess($embedDir, 'Create directory')) {
                New-Item -ItemType Directory -Path $embedDir -Force | Out-Null
            }
        }

        # Escape angle brackets so a stray '</script>' in any value can never break out of the
        # surrounding <script> tag.
        $jsonData = ($embed | ConvertTo-Json -Depth 100 -Compress)

        $embedMeta = [ordered]@{
            generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
            mode         = $Mode
            fileCount    = $results.Count
            files        = @($results | ForEach-Object { [ordered]@{ path = $_.Path; items = $_.Items; sha256 = $_.Sha256 } })
        }
        $metaJson = ($embedMeta | ConvertTo-Json -Depth 5 -Compress)

        # Tenant-specific classification variants (EntraOps mode only) - consumed by the app's
        # variant selector and the Template Comparison view. Always emitted (empty array in
        # Standalone mode) so js/config.js can read window.EOCE_TENANTS unconditionally.
        $tenantsJson = ConvertTo-Json -InputObject ([object[]]$Tenants) -Depth 5 -Compress
        if ([string]::IsNullOrEmpty($tenantsJson)) { $tenantsJson = '[]' }

        $header = "/* Classification Explorer ($Mode mode) - embedded classification data.`n" +
        " Auto-generated by Update-EntraOpsClassificationExplorerData on $((Get-Date).ToUniversalTime().ToString('o')).`n" +
        " Do not edit by hand - re-run the script to refresh. */"
        $content = "$header`nwindow.EOCE_DATA = $jsonData;`nwindow.EOCE_DATA_MANIFEST = $metaJson;`nwindow.EOCE_TENANTS = $tenantsJson;`n"

        if ($PSCmdlet.ShouldProcess($embedPath, 'Write classification-data.js')) {
            Save-EntraOpsReportDataFile -Content $content -LiteralPath $embedPath
            $embedBytes = (Get-Item -LiteralPath $embedPath).Length
            Write-Verbose ("Wrote embedded bundle: {0} ({1:N0} bytes)" -f $embedPath, $embedBytes)
        }
    }

    # --- Build the attack-path catalog from markdown (content/attack-paths/*.md) ---------------
    $attackBytes = 0
    $attackDir = Join-Path $AppRoot 'content/attack-paths'
    if (Test-Path -LiteralPath $attackDir -PathType Container) {
        $mdFiles = Get-ChildItem -LiteralPath $attackDir -Filter '*.md' | Sort-Object Name
        $indexPath = Join-Path $attackDir 'index.json'
        $order = @(Get-Content -LiteralPath $indexPath -Raw -Encoding UTF8 | ConvertFrom-Json)
        $mdFilesById = @{}
        foreach ($file in $mdFiles) { $mdFilesById[$file.BaseName] = $file }
        $mdFiles = @($order | ForEach-Object { $mdFilesById[[string]$_] })
        $mdTexts = @($mdFiles | ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw -Encoding UTF8 })
        $attackJson = ($mdTexts | ConvertTo-Json -Depth 3 -Compress)
        if ($mdTexts.Count -eq 1) { $attackJson = "[$attackJson]" }
        $attackPath = Join-Path $AppRoot 'data/attack-paths.js'
        $attackHeader = "/* Classification Explorer ($Mode mode) - embedded attack-path catalog.`n" +
        " Auto-generated by Update-EntraOpsClassificationExplorerData from content/attack-paths/*.md on $((Get-Date).ToUniversalTime().ToString('o')).`n" +
        " Do not edit by hand - edit the markdown files and re-run the script. */"
        $attackContent = "$attackHeader`nwindow.EOCE_ATTACK_PATHS_MD = $attackJson;`n"
        if ($PSCmdlet.ShouldProcess($attackPath, 'Write data/attack-paths.js')) {
            Save-EntraOpsReportDataFile -Content $attackContent -LiteralPath $attackPath
            $attackBytes = (Get-Item -LiteralPath $attackPath).Length
            Write-Verbose ("Wrote attack-path catalog: {0} ({1} paths, {2:N0} bytes)" -f $attackPath, $mdTexts.Count, $attackBytes)
        }
    }

    # --- Build the Enterprise Access Model Map (Overview Sankey) dataset -----------------------
    $tierMapBytes = 0
    $tierMapCount = 0
    if (-not $SkipEmbed) {
        $tierMapRoles = ConvertTo-TierMapRoles -Embed $embed
        $tierMapPaths = ConvertTo-TierMapPaths -Embed $embed
        $tierMapCount = @($tierMapPaths).Count
        $tierMapObj = [ordered]@{
            generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
            roleCount    = @($tierMapRoles).Count
            pathCount    = $tierMapCount
            roles        = @($tierMapRoles)
            paths        = @($tierMapPaths)
        }
        $tierMapJson = ($tierMapObj | ConvertTo-Json -Depth 10 -Compress)
        $tierMapPath = Join-Path $AppRoot 'data/tier-map.js'
        $tierMapHeader = "/* Classification Explorer ($Mode mode) - embedded Enterprise Access Model Map (Overview Sankey) dataset.`n" +
        " Auto-generated by Update-EntraOpsClassificationExplorerData on $((Get-Date).ToUniversalTime().ToString('o')).`n" +
        " Do not edit by hand - re-run the script to refresh. */"
        $tierMapContent = "$tierMapHeader`nwindow.EOCE_TIER_MAP = $tierMapJson;`n"
        if ($PSCmdlet.ShouldProcess($tierMapPath, 'Write data/tier-map.js')) {
            Save-EntraOpsReportDataFile -Content $tierMapContent -LiteralPath $tierMapPath
            $tierMapBytes = (Get-Item -LiteralPath $tierMapPath).Length
            Write-Verbose ("Wrote EAM Map dataset: {0} ({1} paths, {2:N0} bytes)" -f $tierMapPath, $tierMapCount, $tierMapBytes)
        }
    }

    # --- Build change history (git log) for notifications and the standalone History view ------
    $historyBytes = 0
    $historySummary = @()
    if (-not $SkipHistory) {
        $historyPath = Join-Path $AppRoot 'data/history-data.js'
        $previousHistory = $null
        if (Test-Path -LiteralPath $historyPath -PathType Leaf) {
            try {
                $previousHistoryText = Get-Content -LiteralPath $historyPath -Raw -Encoding UTF8
                $previousHistoryJson = $previousHistoryText -replace '(?s)^.*?window\.EOCE_HISTORY\s*=\s*', '' -replace ';\s*$', ''
                $previousHistory = $previousHistoryJson | ConvertFrom-Json -Depth 15 -ErrorAction Stop
            } catch {
                Write-Verbose "Existing Classification Explorer history could not be used as a notification baseline: $($_.Exception.Message)"
            }
        }

        $gitOk = $true
        try {
            $null = & git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null
            if ($LASTEXITCODE -ne 0) { $gitOk = $false }
        } catch {
            $gitOk = $false
        }
        if (-not $gitOk) {
            Write-Warning "git is unavailable or '$RepoRoot' is not a git working tree - writing empty history data."
        }

        $historyResultSources = [ordered]@{}
        foreach ($key in $HistorySources.Keys) {
            $src = $HistorySources[$key]
            $commits = New-Object System.Collections.Generic.List[object]

            if ($gitOk) {
                $sep = [char]0x1f
                $format = "%H${sep}%ad${sep}%an${sep}%s"
                $logLines = & git -C $RepoRoot log --reverse --date=iso-strict "--format=$format" -- $src.file 2>$null
                if ($LASTEXITCODE -ne 0) { $logLines = @() }
                if ($null -eq $logLines) { $logLines = @() }
                if ($logLines -isnot [System.Array]) { $logLines = @($logLines) }

                $prevIndex = [ordered]@{}
                foreach ($line in $logLines) {
                    if ([string]::IsNullOrWhiteSpace($line)) { continue }
                    $parts = $line -split [string][char]0x1f, 4
                    if ($parts.Count -lt 4) { continue }
                    $sha = $parts[0]; $date = $parts[1]; $author = $parts[2]; $subject = $parts[3]

                    $content = $null
                    try { $content = & git -C $RepoRoot show "${sha}:$($src.file)" 2>$null } catch { $content = $null }
                    $parsedHist = $null
                    if ($content -and $LASTEXITCODE -eq 0) {
                        $text = ($content -join "`n")
                        try { $parsedHist = $text | ConvertFrom-Json } catch { $parsedHist = $null }
                    }
                    $currIndex = ConvertTo-HistoryIndex -Parsed $parsedHist -Kind $src.kind
                    $diff = Compare-HistoryIndex -Previous $prevIndex -Current $currIndex -Kind $src.kind

                    if ($diff.added.Count -gt 0 -or $diff.removed.Count -gt 0 -or $diff.changed.Count -gt 0) {
                        $commits.Add([ordered]@{
                                sha     = $sha.Substring(0, 10)
                                date    = $date
                                author  = $author
                                subject = $subject
                                added   = $diff.added
                                removed = $diff.removed
                                changed = $diff.changed
                            }) | Out-Null
                    }
                    $prevIndex = $currIndex
                }
            }

            $historyResultSources[$key] = [ordered]@{
                label       = $src.label
                kind        = $src.kind
                file        = $src.file
                commitCount = $commits.Count
                commits     = @($commits.ToArray())
            }
            $historySummary += [pscustomobject]@{ Source = $key; Commits = $commits.Count }
            Write-Verbose ("History {0}: {1} commit(s) with changes" -f $key, $commits.Count)
        }

        $previousHeads = @{}
        if ($null -ne $previousHistory -and $null -ne $previousHistory.sources) {
            foreach ($sourceProperty in @($previousHistory.sources.PSObject.Properties)) {
                $previousCommits = @($sourceProperty.Value.commits)
                if ($previousCommits.Count -gt 0 -and $previousCommits[-1].sha) {
                    $previousHeads[$sourceProperty.Name] = [string]$previousCommits[-1].sha
                }
            }
        }
        $currentHeads = @{}
        $notificationSourceKeys = New-Object System.Collections.Generic.List[string]
        $fallbackNotificationSourceKeys = @{}
        foreach ($sourceKey in $historyResultSources.Keys) {
            $currentCommits = @($historyResultSources[$sourceKey].commits)
            if ($currentCommits.Count -eq 0 -or -not $currentCommits[-1].sha) { continue }
            $currentHeads[$sourceKey] = [string]$currentCommits[-1].sha
            if ($previousHeads.ContainsKey($sourceKey) -and $previousHeads[$sourceKey] -ne $currentHeads[$sourceKey]) {
                $notificationSourceKeys.Add($sourceKey) | Out-Null
            }
        }
        if ($notificationSourceKeys.Count -eq 0) {
            # A generated bundle may be committed alongside its source change. In that case the
            # baseline already contains the current heads, so fall back to the newest recorded
            # classification change instead of emitting an empty notification panel.
            $newestCommitDate = $null
            foreach ($sourceKey in $historyResultSources.Keys) {
                $commits = @($historyResultSources[$sourceKey].commits)
                if ($commits.Count -eq 0) { continue }
                $date = [datetimeoffset]$commits[-1].date
                if ($null -eq $newestCommitDate -or $date -gt $newestCommitDate) {
                    $newestCommitDate = $date
                }
            }
            if ($newestCommitDate) {
                foreach ($sourceKey in $historyResultSources.Keys) {
                    $commits = @($historyResultSources[$sourceKey].commits)
                    if ($commits.Count -gt 0 -and ([datetimeoffset]$commits[-1].date) -eq $newestCommitDate) {
                        $notificationSourceKeys.Add($sourceKey) | Out-Null
                        $fallbackNotificationSourceKeys[$sourceKey] = $true
                    }
                }
            }
        }
        $notificationChangeSetId = (@($currentHeads.Keys | Sort-Object | ForEach-Object { "${_}:$($currentHeads[$_])" }) -join '|')
        if ([string]::IsNullOrWhiteSpace($notificationChangeSetId)) { $notificationChangeSetId = 'none' }

        $historyObj = [ordered]@{
            generatedUtc = (Get-Date).ToUniversalTime().ToString('o')
            sources      = $historyResultSources
            notification = [ordered]@{
                changeSetId = $notificationChangeSetId
                sourceKeys  = @($notificationSourceKeys)
            }
        }
        $historyJson = ($historyObj | ConvertTo-Json -Depth 12 -Compress)

        $historyHeader = "/* Classification Explorer ($Mode mode) - embedded classification change history (git log).`n" +
        " Auto-generated by Update-EntraOpsClassificationExplorerData on $((Get-Date).ToUniversalTime().ToString('o')).`n" +
        " Do not edit by hand - re-run the script to refresh. */"
        $historyContent = "$historyHeader`nwindow.EOCE_HISTORY = $historyJson;`n"
        if ($PSCmdlet.ShouldProcess($historyPath, 'Write data/history-data.js')) {
            Save-EntraOpsReportDataFile -Content $historyContent -LiteralPath $historyPath
            $historyBytes = (Get-Item -LiteralPath $historyPath).Length
            Write-Verbose ("Wrote change history: {0} ({1:N0} bytes)" -f $historyPath, $historyBytes)
        }

        $notificationSources = [ordered]@{}
        foreach ($sourceKey in $notificationSourceKeys) {
            $source = $historyResultSources[$sourceKey]
            $commits = @($source.commits)
            if ($commits.Count -eq 0) { continue }
            $notificationCommits = @($commits)
            if ($fallbackNotificationSourceKeys.ContainsKey($sourceKey)) {
                # The committed history already contains the current head. Preserve the newest
                # commit selected by the fallback instead of filtering everything after that head.
                $notificationCommits = @($commits[-1])
            } elseif ($previousHeads.ContainsKey($sourceKey)) {
                $previousIndex = -1
                for ($commitIndex = 0; $commitIndex -lt $commits.Count; $commitIndex++) {
                    if ($commits[$commitIndex].sha -eq $previousHeads[$sourceKey]) {
                        $previousIndex = $commitIndex
                        break
                    }
                }
                if ($previousIndex -ge 0) {
                    $notificationCommits = @($commits | Select-Object -Skip ($previousIndex + 1))
                }
            }
            if ($notificationCommits.Count -eq 0) { continue }
            $notificationSources[$sourceKey] = [ordered]@{
                label   = $source.label
                kind    = $source.kind
                commits = $notificationCommits
            }
        }
        $notificationObj = [ordered]@{
            sources      = $notificationSources
            notification = $historyObj.notification
        }
        $notificationJson = ($notificationObj | ConvertTo-Json -Depth 12 -Compress)
        $notificationPath = Join-Path $AppRoot 'data/notification-data.js'
        $notificationContent = "/* Classification Explorer ($Mode mode) - compact classification change notifications.`n" +
        " Auto-generated by Update-EntraOpsClassificationExplorerData; do not edit by hand. */`n" +
        "window.EOCE_NOTIFICATION_DATA = $notificationJson;"
        if ($PSCmdlet.ShouldProcess($notificationPath, 'Write data/notification-data.js')) {
            Save-EntraOpsReportDataFile -Content $notificationContent -LiteralPath $notificationPath
            Write-Verbose ("Wrote notification summary: {0}" -f $notificationPath)
        }
    }

    # --- Summary ---------------------------------------------------------------------------
    $totalBytes = ($results | Measure-Object -Property Bytes -Sum).Sum
    Write-Host ("Classification Explorer ($Mode mode) - data refresh complete.") -ForegroundColor Green
    Write-Host (" Source files : {0}" -f $results.Count)
    Write-Host (" Total size : {0:N0} KB" -f ([math]::Round($totalBytes / 1KB)))
    Write-Host (" Output folder : {0}" -f $AppRoot)
    if (-not $SkipManifest) {
        Write-Host (" Manifest : data-manifest.json")
    }
    if (-not $SkipEmbed) {
        Write-Host (" Embedded data : data/classification-data.js ({0:N0} KB) - app runs with no web server" -f ([math]::Round($embedBytes / 1KB)))
    }
    if ($attackBytes -gt 0) {
        Write-Host (" Attack paths : data/attack-paths.js ({0:N0} KB) - from content/attack-paths/*.md" -f ([math]::Round($attackBytes / 1KB)))
    }
    if ($tierMapBytes -gt 0) {
        Write-Host (" EAM Map : data/tier-map.js ({0:N0} KB, {1} paths) - Overview Sankey" -f ([math]::Round($tierMapBytes / 1KB)), $tierMapCount)
    }
    if ($Mode -eq 'EntraOps') {
        Write-Host (" Tenants : {0} tenant-specific variant(s)" -f $Tenants.Count)
    }
    if (-not $SkipHistory) {
        Write-Host (" History : data/history-data.js ({0:N0} KB) - git log per source:" -f ([math]::Round($historyBytes / 1KB)))
        foreach ($s in $historySummary) { Write-Host (" {0,-20} {1} commit(s)" -f $s.Source, $s.Commits) }
    }

    if ($PassThru) { $results }
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDc4SUlXDdzq74l
# 048+HxiIvv8fghLegsj2/fCYq56iMaCCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# 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
# ARUwLwYJKoZIhvcNAQkEMSIEIFio/YOmimDZ+Wofyl0tJwsHL2Na3Wc7Sk/rmqoq
# lmwKMA0GCSqGSIb3DQEBAQUABIIBgE5IH63QcSQBaqzKD2dv8EoP5xAk4zqc61eG
# TMuTN9bX2nKPAqTowyx3nf+SWWXkOy5eLin+ZXBNyqjyfwIosKqamRUqrN53ElVt
# 2JNicLfPh4vSOtesuM1/bPJcXqlQ9a0GVQBVfc+RmURxfxlQQ4YRvTSvRWA+sVta
# E9Ei54GuyfyRwZ1oLVl6a4fmx1GQlpd/Py79+tcAjqq3mZxV6LgDyNAsTc/zqrfV
# 2oh/+x0Mi4f5Mx9c99gtxqKKGYv4qSp3pbybZwKJKmuuxziaHbPi9/u7FenImIkS
# ddSM+lmZwCGhXtVgZ9X7zjf3zDCy0XgBl5BTR6fCfzFoeWLAQJSj9m+0bER0PlVz
# 8H8uDDMHTZiOAy+9fwGWDRY2fx7uFcIvQEdfJXJizW40N/AYuxBug9HstnjxJImJ
# BfBpVDrY1S08lsa23MxJusMLP/tSZ8TfK9uiqY81JThlAc3DRc2daZ83hCjHxoyE
# CgSbzUuXTvoLQgYeeCqPkpIGQRaRCqGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTQyMDBaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDCeZSZJlBWGapjzwh3F4i/oveGiB19Jm+ry
# HnEeQaXhxa6sKvYJ1xKoYbsm1oVFQU0wgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAqv6QVr8Nfm2vN5/00RJQ7PFZw4IbViGKjotZ/N6wk9UqKozn
# BNjXmIUV5+ZLTuZf7BtM434nsO8cDhX2WMFX3rHmRez9NLBZThTbfOxg3QZLvRSQ
# nww4vm60N8cLMtmisqcJv3wDbbqMW6EHX8NmGDaPZhwp1BILmiXkgnconoJFVtlz
# 05jLe0Kx0TebmLR4aFxAxbQtvR3uao9DwRbi7ZWguERUwUmQ1Bq+2om1rSdsRlwM
# lJdLzfl5zw/c4j/qk+11Rd8JTgo1mLhC0ZHocRfTxaYrwb7YcXMfITaqy4H2huzY
# B8fXNvlWbEbntbGH61lcy/DrKUqK74pnOiqBZDQq2/9UzS1IDmkU/VyYA6WrdPAe
# lgN0yaA6Qo7EJGFYnipLEditXv1IVR/9ZwOgja1BYoiEP52brprDTQMgazn8ENAv
# f5iag6UXUGbBcijImJwzro7vLyL3eiEU591srXfklux6ik9wG4fxzIWiKBQivEyh
# 9LBaBwI0Wl3mzukj80Ul+hzmZl7ELHRSM2hsSq73mJItZ6qUYxn3VLr8Xx01PIbb
# 2Wx8xYuBWezKFsgOeflPA68bu0IeONnP6evrBQPEGjVVVKFUrnql7yjz1+WZOAEk
# 5dDD4cBrWxjlcVjh5/sGX/hqHd2ifxAqSovNYzRXTppueHfFyN9XW4egJsw=
# SIG # End signature block