Public/TenantGovernance/Save-EntraOpsTenantGovernanceSnapshotJson.ps1

<#
.SYNOPSIS
    Create an EntraOps Tenant Governance snapshot and persist each captured resource as an
    individual JSON file tracked in Git.

.DESCRIPTION
    Wraps Get-EntraOpsTenantGovernanceSnapshot to capture the configured Microsoft Entra resources
    and stores each captured resource as its own file under <EntraOpsBaseFolder>/TenantGovernance/Snapshots,
    organized in subfolders:
      <ExportFolder>/<resourceType>/<first segment of displayName>/<remaining displayName>.json
    For example, a resource with resourceType "microsoft.entra.conditionalaccesspolicy" and
    displayName "AADConditionalAccessPolicy-Workplace 3 - Allow non-compliant Browser with MFA and
    Session Controls" is stored at:
      <ExportFolder>/microsoft.entra.conditionalaccesspolicy/AADConditionalAccessPolicy/Workplace 3 - Allow non-compliant Browser with MFA and Session Controls.json
    The displayName is split on its first "-" only: everything before it becomes the subfolder,
    everything after it (which may itself contain further "-" characters) becomes the file name.
    Every path segment is normalized (see ConvertTo-EntraOpsSafeFilePathSegment) so the resulting
    files can be created, committed, and checked out on Windows, Linux, and macOS alike, regardless
    of the platform the snapshot was collected on.
    Resource-type folders are staged and replaced as a unit, so resources removed from the tenant do
    not remain as stale files. Historical point-in-time versions aren't kept as separate files but
    are tracked through the repository's Git commit history instead, the same approach used for
    PrivilegedEAM data and Privilege History (see Reports/PrivilegeHistory/README.md).

    Snapshot metadata (SnapshotId, DisplayName, CapturedDateTime, ResourcesToInclude, ResourceCount,
    resource-type counts, and completion status) is persisted in <ExportFolder>/.SnapshotManifest.json
    and also shown as a summary in the command output. The latest attempt is recorded separately in
    <ExportFolder>/.LastAttemptManifest.json. When Graph reports a partiallySuccessful job, resource
    types without Graph errors are published from staging. A type whose only errors are per-resource
    export failures (for example Request_ResourceNotFound for one access package policy) still
    returns every resource that did export: those are published, and the previously published file
    of each resource the attempt did not return is retained so a failed export never shows up as a
    deleted resource in Git history. Such types are marked PublishedWithErrors with their
    RetainedResourceCount. Types whose backing workload could not be reached (ConnectionError) or that
    returned no resource at all retain their prior files and are marked PreservedStale with their
    published and attempted snapshot Ids. If Graph provides no parseable per-type errors, no
    downloaded types are published; all existing files are preserved as stale and the operation
    completes with a warning instead of failing the workflow.

    Before creating the snapshot, prerequisites (the "ConfigurationMonitoring.Read.All" permission
    and the Microsoft Tenant Configuration Management service principal) are validated
    automatically. If they are not met, run Register-EntraOpsTenantGovernanceServicePrincipal and/or
    New-EntraOpsWorkloadIdentity first (see Test-EntraOpsTenantGovernancePrerequisite to troubleshoot).

    Large snapshots can take a while to complete on the Microsoft Graph side. Use -Operation Start
    to create a job and persist its Id, then -Operation Collect in a later run to check it once and
    download completed results. Use -Operation RunAndWait for an interactive create-and-wait execution.

.PARAMETER ExportFolder
    Folder where the per-resource snapshot files are stored. Default is <EntraOpsBaseFolder>/TenantGovernance/Snapshots.

.PARAMETER ResourcesToInclude
    Array of Microsoft Entra resource types to include in the snapshot. Defaults to the recommended
    tenant governance resource set.

.PARAMETER SnapshotDisplayNamePrefix
    Prefix used for the snapshot's display name on the Microsoft Graph side (visible in the admin
    center / when listing snapshot jobs via Graph). Default is "EntraOps TG". The Microsoft Graph
    UTCM API only allows alphabets, numbers, and spaces in the resulting display name (any other
    characters, e.g. hyphens, are stripped automatically) and enforces a length between 8 and 32
    characters (longer names are truncated automatically). Purely cosmetic - it has no effect on
    the local file name or on how the snapshot is retrieved.

.PARAMETER TimeoutInSeconds
    Maximum time (in seconds) to wait for the snapshot job to complete. Default is 900 (15 minutes).
    If the wait expires, the still-running job Id is persisted as a pending job so it can be
    retrieved later with -Operation Collect.

.PARAMETER Operation
    Explicit snapshot operation:
    - Start: create a new job, persist its Id, and return without checking or downloading it.
    - Collect: check the persisted job once; download it only when complete. Never creates a job.
    - RunAndWait: create a new job and poll until complete. Intended for manual use.
    Start and RunAndWait refuse to proceed while a pending job exists. Collect is a successful no-op when
    no job is pending, which makes scheduled collection retries safe after an earlier retry succeeds.

.PARAMETER SkipWaitForCompletion
    Deprecated compatibility alias for -Operation Start. Do not combine it with -Operation.

.PARAMETER SkipPrerequisiteCheck
    Skip the automatic prerequisite validation (Test-EntraOpsTenantGovernancePrerequisite) before
    creating the snapshot job. Default is $false.

.PARAMETER SnapshotResourceFileNaming
    Controls the file name used for each persisted resource file (the folder structure - resourceType
    then the first displayName segment - is unaffected). Possible values:
    - "DisplayName" (default): the file is named after the remainder of the resource's displayName
      (everything after its first "-"), e.g. "Legacy 1 - Block legacy auth.json". Human-readable, but
      the file is effectively renamed/re-created if the resource's displayName changes.
    - "ResourceId": the file is named after the resource's underlying Id (typically a GUID), e.g.
      "e9f699a0-fb3f-48da-84c6-6351163b2109.json". Stable across renames, so Git history tracks
      changes to the same resource as diffs to the same file instead of a rename. Falls back to
      DisplayName-based naming for resource types that don't expose an Id.

.EXAMPLE
    Save-EntraOpsTenantGovernanceSnapshotJson

.EXAMPLE
    Save a snapshot of Conditional Access and Named Locations only.
    Save-EntraOpsTenantGovernanceSnapshotJson -ResourcesToInclude @("microsoft.entra.conditionalAccessPolicy","microsoft.entra.namedLocationPolicy")

.EXAMPLE
    Start a snapshot job without waiting (e.g. a scheduled run at 06:00), then check and save it in a
    later, separate run (e.g. scheduled collector runs at 07:00, 07:30, and 08:00).
    Save-EntraOpsTenantGovernanceSnapshotJson -Operation Start
    # ... in a separate, later run ...
    Save-EntraOpsTenantGovernanceSnapshotJson -Operation Collect

.EXAMPLE
    Create a snapshot and wait for it during a manual run.
    Save-EntraOpsTenantGovernanceSnapshotJson -Operation RunAndWait -TimeoutInSeconds 3300
#>

function Save-EntraOpsTenantGovernanceSnapshotJson {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $false)]
        [System.String]$ExportFolder = "$EntraOpsBaseFolder/TenantGovernance/Snapshots"
        ,
        [Parameter(Mandatory = $false)]
        [Array]$ResourcesToInclude = (Get-EntraOpsTenantGovernanceResourceDefinition).DefaultResources
        ,
        [Parameter(Mandatory = $false)]
        [System.String]$SnapshotDisplayNamePrefix = "EntraOps TG"
        ,
        [Parameter(Mandatory = $false)]
        [System.Int32]$TimeoutInSeconds = 900
        ,
        [Parameter(Mandatory = $false)]
        [ValidateSet('Start', 'Collect', 'RunAndWait')]
        [System.String]$Operation = 'RunAndWait'
        ,
        [Parameter(Mandatory = $false)]
        [switch]$SkipWaitForCompletion
        ,
        [Parameter(Mandatory = $false)]
        [switch]$SkipPrerequisiteCheck
        ,
        [Parameter(Mandatory = $false)]
        [ValidateSet('DisplayName', 'ResourceId')]
        [System.String]$SnapshotResourceFileNaming = 'DisplayName'
    )

    $ErrorActionPreference = "Stop"

    # --- Path safety: ensure ExportFolder is under the expected base directory ---
    $ResolvedExportFolder = [System.IO.Path]::GetFullPath($ExportFolder)
    $ResolvedBaseFolder = [System.IO.Path]::GetFullPath($EntraOpsBaseFolder)
    if (-not (Test-EntraOpsPathWithinRoot -Path $ExportFolder -Root $EntraOpsBaseFolder)) {
        throw "Security check failed: ExportFolder '$ResolvedExportFolder' is not under the expected base directory '$ResolvedBaseFolder'. Aborting to prevent accidental data loss."
    }

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

    $PendingJobFilePath = Join-Path -Path $ExportFolder -ChildPath ".PendingSnapshotJob.json"
    $SnapshotManifestFilePath = Join-Path -Path $ExportFolder -ChildPath ".SnapshotManifest.json"
    $LastAttemptManifestFilePath = Join-Path -Path $ExportFolder -ChildPath ".LastAttemptManifest.json"

    if ($SkipWaitForCompletion) {
        if ($PSBoundParameters.ContainsKey('Operation')) {
            throw "SkipWaitForCompletion is a deprecated alias for -Operation Start and cannot be combined with -Operation."
        }
        Write-Warning "SkipWaitForCompletion is deprecated. Use -Operation Start instead."
        $Operation = 'Start'
    }

    #region Read pending job state
    $PendingJob = $null
    if (Test-Path -Path $PendingJobFilePath -ErrorAction SilentlyContinue) {
        try {
            $PendingJob = Get-Content -Path $PendingJobFilePath -Raw | ConvertFrom-Json
        } catch {
            throw "Failed to read pending snapshot job state file $PendingJobFilePath. Refusing to create or collect a snapshot until the state is repaired. Error: $_"
        }
    }
    #endregion

    #region Execute the requested state-machine operation
    $HasPendingJob = $PendingJob -and -not [string]::IsNullOrWhiteSpace($PendingJob.SnapshotJobId)
    if ($Operation -eq 'Collect') {
        if (-not $HasPendingJob) {
            Write-Output "No pending Tenant Governance snapshot job exists. Nothing to collect."
            return [PSCustomObject]@{ Operation = 'Collect'; Status = 'NoPendingJob' }
        }

        Write-Output "Checking pending snapshot job $($PendingJob.SnapshotJobId) started at $($PendingJob.CreatedDateTime)..."
        $Snapshot = Get-EntraOpsTenantGovernanceSnapshot -SnapshotJobId $PendingJob.SnapshotJobId -ResourcesToInclude @($PendingJob.ResourcesToInclude) -SkipWaitForCompletion -SkipPrerequisiteCheck:$SkipPrerequisiteCheck
    } else {
        if ($HasPendingJob) {
            throw "Pending snapshot job $($PendingJob.SnapshotJobId) already exists. Run -Operation Collect before starting another job."
        }

        $Timestamp = (Get-Date).ToUniversalTime()
        $SnapshotDisplayName = "$($SnapshotDisplayNamePrefix) $($Timestamp.ToString('yyyyMMddHHmmss'))"
        $SnapshotDisplayName = ($SnapshotDisplayName -replace '[^a-zA-Z0-9 ]', ' ') -replace '\s+', ' '
        $SnapshotDisplayName = $SnapshotDisplayName.Trim()
        if ($SnapshotDisplayName.Length -gt 32) {
            $SnapshotDisplayName = $SnapshotDisplayName.Substring(0, 32).TrimEnd()
        }
        if ($SnapshotDisplayName.Length -lt 8) {
            $SnapshotDisplayName = $SnapshotDisplayName.PadRight(8, '0')
        }

        $Snapshot = Get-EntraOpsTenantGovernanceSnapshot -ResourcesToInclude $ResourcesToInclude -SnapshotDisplayName $SnapshotDisplayName -TimeoutInSeconds $TimeoutInSeconds -SkipWaitForCompletion:($Operation -eq 'Start') -SkipPrerequisiteCheck:$SkipPrerequisiteCheck
    }
    #endregion

    #region If the snapshot job is still pending, persist its state and return without touching the main snapshot file
    if ($Snapshot.Status -ne "Completed") {
        $PendingJobOutputObject = [ordered]@{
            SnapshotJobId       = $Snapshot.SnapshotId
            SnapshotDisplayName = $Snapshot.DisplayName
            CreatedDateTime     = if ($HasPendingJob) { $PendingJob.CreatedDateTime } else { $Snapshot.CreatedDateTime }
            ResourcesToInclude  = @($Snapshot.ResourcesToInclude)
            LastCheckedDateTime = (Get-Date).ToUniversalTime().ToString('o')
            LastKnownStatus     = $Snapshot.Status
            LastPollingError    = $Snapshot.PollingError
        }
        $PendingJobOutputObject | ConvertTo-Json -Depth 10 | Out-File -Path $PendingJobFilePath -Encoding utf8
        Write-Output "Snapshot job $($Snapshot.SnapshotId) is $($Snapshot.Status). State saved to $PendingJobFilePath. Collect it later with Save-EntraOpsTenantGovernanceSnapshotJson -Operation Collect."
        return $PendingJobOutputObject
    }

    #endregion

    $SnapshotJobStatus = if ([string]::IsNullOrWhiteSpace($Snapshot.SnapshotJobStatus)) { 'completed' } else { $Snapshot.SnapshotJobStatus }

    $ParsedErrorDetails = @(@($Snapshot.ErrorDetails) | Where-Object { $_ } | ConvertFrom-EntraOpsTenantGovernanceSnapshotErrorDetail)
    $SnapshotDiagnostics = @(
        $ParsedErrorDetails |
        Group-Object ResourceType, ErrorCategory, ErrorCode, NormalizedMessage |
        ForEach-Object {
            $First = $_.Group[0]
            [PSCustomObject][ordered]@{
                ResourceType    = if ($First.ResourceType) { $First.ResourceType.ToLowerInvariant() } else { $null }
                ErrorCategory   = $First.ErrorCategory
                ErrorCode       = $First.ErrorCode
                Occurrences     = $_.Count
                Message         = $First.NormalizedMessage
                RemediationHint = switch ($First.ErrorCategory) {
                    'ConnectionError' { 'Retry the snapshot and verify the backing workload/API is available.' }
                    'ExportError' { 'Review the resource-type permission, referenced object and UTCM support, then retry.' }
                    default { 'Inspect the protected workflow diagnostics or query the snapshot job with Get-EntraOpsTenantGovernanceSnapshotReport.' }
                }
            }
        }
    )
    [string[]]$ResourceTypesWithErrors = @(
        $ParsedErrorDetails |
        ForEach-Object { $_.ResourceType } |
        Where-Object { $_ } |
        ForEach-Object { $_.ToLowerInvariant() } |
        Select-Object -Unique
    )
    # Graph does not guarantee errorDetails order. Keep the manifest's StaleResourceTypes stable
    # when the same resource types fail in a different order on a later run.
    [Array]::Sort($ResourceTypesWithErrors, [StringComparer]::Ordinal)
    $RequestedResourceTypes = @(
        @($Snapshot.ResourcesToInclude) |
        Where-Object { $_ } |
        ForEach-Object { $_.ToLowerInvariant() } |
        Select-Object -Unique
    )
    $UnorderedAttemptResourceTypeCounts = @{}
    foreach ($Resource in @($Snapshot.Resources)) {
        $ResourceType = if ($Resource.resourceType) { $Resource.resourceType.ToLowerInvariant() } else { 'unknown' }
        if (-not $UnorderedAttemptResourceTypeCounts.ContainsKey($ResourceType)) { $UnorderedAttemptResourceTypeCounts[$ResourceType] = 0 }
        $UnorderedAttemptResourceTypeCounts[$ResourceType]++
    }
    # UTCM reports per-resource export failures at the type level while still returning every
    # resource of that type that did export. Only a type whose errors are all ExportError and that
    # returned at least one resource qualifies; a ConnectionError means the whole workload was
    # unreachable and the returned set cannot be trusted.
    [string[]]$ResourceTypesWithRetainedResources = @(
        foreach ($ResourceType in $ResourceTypesWithErrors) {
            $Categories = @($ParsedErrorDetails | Where-Object { $_.ResourceType -and $_.ResourceType.ToLowerInvariant() -eq $ResourceType } | ForEach-Object { $_.ErrorCategory } | Select-Object -Unique)
            if ($Categories.Count -gt 0 -and @($Categories | Where-Object { $_ -ne 'ExportError' }).Count -eq 0 -and [int]$UnorderedAttemptResourceTypeCounts[$ResourceType] -gt 0) {
                $ResourceType
            }
        }
    )
    [string[]]$StaleResourceTypes = @($ResourceTypesWithErrors | Where-Object { $ResourceTypesWithRetainedResources -notcontains $_ })
    if ($SnapshotJobStatus -eq 'partiallySuccessful' -and $ResourceTypesWithErrors.Count -eq 0) {
        $PreviousManifest = $null
        if (Test-Path -Path $SnapshotManifestFilePath -ErrorAction SilentlyContinue) {
            try {
                $PreviousManifest = Get-Content -Path $SnapshotManifestFilePath -Raw | ConvertFrom-Json -Depth 10
            } catch {
                Write-Warning "The previous snapshot manifest could not be read. Existing resource files remain preserved, but their source snapshot cannot be identified. Error: $_"
            }
        }
        $PreviousResourceTypeStates = @{}
        foreach ($PreviousState in @($PreviousManifest.ResourceTypeStates)) {
            if ($PreviousState.ResourceType) { $PreviousResourceTypeStates[$PreviousState.ResourceType.ToLowerInvariant()] = $PreviousState }
        }
        $PreviousCompleteSnapshotId = if ($PreviousManifest.IsComplete -eq $true) { $PreviousManifest.SnapshotId } else { $null }
        $PublishedResourceTypeCounts = [ordered]@{}
        foreach ($ResourceType in $RequestedResourceTypes) {
            $PublishedResourceFolder = Join-Path -Path $ExportFolder -ChildPath $ResourceType
            $PublishedResourceTypeCounts[$ResourceType] = if (Test-Path -Path $PublishedResourceFolder -PathType Container) {
                @(Get-ChildItem -Path $PublishedResourceFolder -Filter '*.json' -File -Recurse -ErrorAction SilentlyContinue).Count
            } else { 0 }
        }
        $AmbiguousResourceTypeStates = @(
            foreach ($ResourceType in $RequestedResourceTypes) {
                $PreviousState = $PreviousResourceTypeStates[$ResourceType]
                $PublishedSnapshotId = if ($PreviousState.PublishedSnapshotId) { $PreviousState.PublishedSnapshotId } else { $PreviousCompleteSnapshotId }
                [PSCustomObject][ordered]@{
                    ResourceType           = $ResourceType
                    Status                 = 'PreservedStale'
                    CapturedResourceCount  = 0
                    AttemptResourceCount   = 0
                    PublishedResourceCount = $PublishedResourceTypeCounts[$ResourceType]
                    PublishedSnapshotId    = $PublishedSnapshotId
                    PublishedSource        = if ($PublishedSnapshotId) { 'Snapshot' } else { 'UnknownLegacySnapshot' }
                    AttemptSnapshotId      = $Snapshot.SnapshotId
                }
            }
        )
        $AmbiguousPartialManifest = [ordered]@{
            SnapshotId                  = $Snapshot.SnapshotId
            SnapshotDisplayName         = $Snapshot.DisplayName
            SnapshotJobStatus           = $SnapshotJobStatus
            CapturedDateTime            = $Snapshot.CreatedDateTime
            ResourcesToInclude          = @($Snapshot.ResourcesToInclude)
            CapturedResourceCount       = $Snapshot.ResourceCount
            AttemptResourceTypeCounts   = [ordered]@{}
            PublishedResourceTypeCounts = $PublishedResourceTypeCounts
            ResourceTypeFileCounts      = $PublishedResourceTypeCounts
            PublishedResourceTypes      = @()
            StaleResourceTypes          = @($RequestedResourceTypes)
            ResourceTypeStates          = @($AmbiguousResourceTypeStates)
            Diagnostics                 = @($SnapshotDiagnostics)
            ErrorDetailsUnavailable     = $SnapshotDiagnostics.Count -eq 0
            IsComplete                  = $false
        }
        $AmbiguousPartialManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $LastAttemptManifestFilePath -Encoding utf8
        $AmbiguousPartialManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $SnapshotManifestFilePath -Encoding utf8
        if (Test-Path -Path $PendingJobFilePath -ErrorAction SilentlyContinue) {
            Remove-Item -Path $PendingJobFilePath -Force
        }
        Write-Warning "Tenant Governance snapshot job $($Snapshot.SnapshotId) completed as '$SnapshotJobStatus', but Graph returned no parseable resource-type errors. No downloaded resource types were published; all existing resource files were preserved as stale."
        return [PSCustomObject]@{
            TenantId            = $Global:TenantIdContext
            TenantName          = $Global:TenantNameContext
            SnapshotId          = $Snapshot.SnapshotId
            SnapshotDisplayName = $Snapshot.DisplayName
            SnapshotJobStatus   = $SnapshotJobStatus
            CapturedDateTime    = $Snapshot.CreatedDateTime
            ResourcesToInclude  = @($Snapshot.ResourcesToInclude)
            ResourceCount       = $Snapshot.ResourceCount
        }
    }

    $PublishableResourceTypes = @($RequestedResourceTypes | Where-Object { $StaleResourceTypes -notcontains $_ })
    $StagingFolder = Join-Path -Path $ExportFolder -ChildPath ".staging-$($Snapshot.SnapshotId)"
    $BackupFolder = Join-Path -Path $ExportFolder -ChildPath ".backup-$($Snapshot.SnapshotId)"

    # Recover an interrupted earlier promotion of this same job before rebuilding its staging area.
    if (Test-Path -Path $BackupFolder -ErrorAction SilentlyContinue) {
        foreach ($BackupResourceFolder in @(Get-ChildItem -Path $BackupFolder -Directory -ErrorAction SilentlyContinue)) {
            $PublishedResourceFolder = Join-Path -Path $ExportFolder -ChildPath $BackupResourceFolder.Name
            if (-not (Test-Path -Path $PublishedResourceFolder -ErrorAction SilentlyContinue)) {
                Move-Item -Path $BackupResourceFolder.FullName -Destination $PublishedResourceFolder -Force
            }
        }
        Remove-Item -Path $BackupFolder -Recurse -Force
    }
    if (Test-Path -Path $StagingFolder -ErrorAction SilentlyContinue) {
        Remove-Item -Path $StagingFolder -Recurse -Force
    }

    #region Stage each captured resource as an individual file under <resourceType>/<displayName segment 1>/<displayName segment 2>.json
    # File names are resolved for all resources first: normalization and case-insensitive filesystems
    # both map several display names onto the same file name, and which of the colliding resources
    # keeps the plain name must not depend on the order in which Graph returned them.

    # Collections whose Graph order is unstable but semantically irrelevant, keyed by lowercase
    # resource type. Keep this registry resource-specific: identically named collections in another
    # resource type can be positional (for example, role-setting approvalStages) and must retain
    # Graph's order.
    $CollectionPathsToSortByResourceType = @{
        'microsoft.entra.administrativeunit'  = @('properties.Members', 'properties.ScopedRoleMembers')
        'microsoft.entra.group'               = @('properties.Members')
        'microsoft.entra.authorizationpolicy' = @('properties.PermissionGrantPolicyIdsAssignedToDefaultUserRole')
    }
    $StagedResources = [System.Collections.Generic.List[object]]::new()
    $CapturedResourceIdentities = @{}
    Write-Output "Saving $($Snapshot.ResourceCount) resource(s) under $ExportFolder (organized by resource type and display name)..."
    foreach ($Resource in @($Snapshot.Resources)) {
        $ResourceType = if ($Resource.resourceType) { $Resource.resourceType } else { "unknown" }
        $DisplayName = if ($Resource.displayName) { $Resource.displayName } else { "Unnamed" }

        # UTCM sometimes appends nested hashtable .ToString() values to partner display names.
        # Partners have no independent display name; their tenant ID is the stable identity.
        if ($ResourceType -eq 'microsoft.entra.crossTenantAccessPolicyConfigurationPartner') {
            $PartnerTenantId = $Resource.properties.partnerTenantId
            if ([string]::IsNullOrWhiteSpace([string]$PartnerTenantId) -and $DisplayName -match '(?i)\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\b') {
                $PartnerTenantId = $Matches[0]
            }
            if (-not [string]::IsNullOrWhiteSpace([string]$PartnerTenantId)) {
                $DisplayName = "AADCrossTenantAccessPolicyConfigurationPartner-$PartnerTenantId"
                if ($Resource -is [System.Collections.IDictionary]) {
                    $Resource['displayName'] = $DisplayName
                } else {
                    $Resource.displayName = $DisplayName
                }
            }
        }

        # Split the displayName on its first "-" only: everything before becomes the subfolder,
        # everything after (which may itself contain further "-" characters) becomes the file name.
        $DelimiterIndex = $DisplayName.IndexOf('-')
        if ($DelimiterIndex -ge 0) {
            $CategorySegment = $DisplayName.Substring(0, $DelimiterIndex).Trim()
            $NameSegment = $DisplayName.Substring($DelimiterIndex + 1).Trim()
        } else {
            $CategorySegment = "General"
            $NameSegment = $DisplayName.Trim()
        }
        if ([string]::IsNullOrWhiteSpace($CategorySegment)) { $CategorySegment = "General" }
        if ([string]::IsNullOrWhiteSpace($NameSegment)) { $NameSegment = $DisplayName.Trim() }

        # Determine the leaf file name based on -SnapshotResourceFileNaming. "ResourceId" falls back
        # to the DisplayName-based name segment if the resource doesn't expose a usable Id (e.g.
        # singleton resources keyed by IsSingleInstance rather than Id).
        $FileNameSegment = $NameSegment
        if ($SnapshotResourceFileNaming -eq 'ResourceId') {
            $ResourceId = $Resource.properties.Id
            if (-not [string]::IsNullOrWhiteSpace($ResourceId)) {
                $FileNameSegment = $ResourceId
            } else {
                Write-Verbose "Resource '$DisplayName' has no usable Id; falling back to DisplayName-based file naming."
            }
        }

        # Normalize each path segment so the resulting file can be created, committed, and checked
        # out on every platform (see ConvertTo-EntraOpsSafeFilePathSegment) and so oversized display
        # names cannot exceed the filesystem's per-component length limit.
        # Resource type identifiers are case-insensitive. Canonicalize their directory name because
        # publication and manifest processing use lowercase identifiers; otherwise a mixed-case
        # resource type stages into a different directory on case-sensitive filesystems.
        $SafeResourceType = ConvertTo-EntraOpsSafeFilePathSegment -Segment $ResourceType.ToLowerInvariant() -FallbackName 'unknown'
        $SafeCategorySegment = ConvertTo-EntraOpsSafeFilePathSegment -Segment $CategorySegment -FallbackName 'General'
        $SafeFileNameSegment = ConvertTo-EntraOpsSafeFilePathSegment -Segment $FileNameSegment -FallbackName 'Unnamed'

        $ResourceFolder = Join-Path -Path (Join-Path -Path $StagingFolder -ChildPath $SafeResourceType) -ChildPath $SafeCategorySegment
        $ResourceFilePath = Join-Path -Path $ResourceFolder -ChildPath "$SafeFileNameSegment.json"

        # Property names are sorted at every nesting level. Collection order is retained unless the
        # collection is registered above as a known semantic set.
        $CanonicalResourceType = $ResourceType.ToLowerInvariant()
        [string[]]$CollectionPathsToSort = @()
        if ($CollectionPathsToSortByResourceType.ContainsKey($CanonicalResourceType)) {
            $CollectionPathsToSort = $CollectionPathsToSortByResourceType[$CanonicalResourceType]
        }
        $SortedResource = ConvertTo-EntraOpsSortedObject -InputObject $Resource -CollectionPathsToSort $CollectionPathsToSort
        $ResourceJson = $SortedResource | ConvertTo-Json -Depth 10
        # The Id stays stable while the resource is edited or renamed, so it is preferred over the
        # content as the discriminator seed for resources that need one.
        $ResourceIdentifier = [string]$Resource.properties.Id
        if (-not [string]::IsNullOrWhiteSpace($ResourceIdentifier)) {
            $CanonicalIdentity = "$($ResourceType.ToLowerInvariant())|$($ResourceIdentifier.Trim().ToLowerInvariant())"
            if ($CapturedResourceIdentities.ContainsKey($CanonicalIdentity)) {
                # This condition is deterministic for the job, so retrying the same job can never
                # succeed. Release the pending-job state and record the failure so the next Start
                # can create a new job and the report/analyzer can show why this one was rejected.
                $DuplicateMessage = "Snapshot job $($Snapshot.SnapshotId) returned duplicate canonical resource identity '$CanonicalIdentity' for '$DisplayName' and '$($CapturedResourceIdentities[$CanonicalIdentity])'. No snapshot resource folders were published."
                $DuplicateAttemptManifest = [ordered]@{
                    SnapshotId                  = $Snapshot.SnapshotId
                    SnapshotDisplayName         = $Snapshot.DisplayName
                    SnapshotJobStatus           = $SnapshotJobStatus
                    CapturedDateTime            = $Snapshot.CreatedDateTime
                    ResourcesToInclude          = @($Snapshot.ResourcesToInclude)
                    CapturedResourceCount       = $Snapshot.ResourceCount
                    AttemptResourceTypeCounts   = [ordered]@{}
                    PublishedResourceTypeCounts = [ordered]@{}
                    ResourceTypeFileCounts      = [ordered]@{}
                    PublishedResourceTypes      = @()
                    StaleResourceTypes          = @($RequestedResourceTypes)
                    ResourceTypeStates          = @()
                    Diagnostics                 = @(
                        [PSCustomObject][ordered]@{
                            ResourceType    = $ResourceType.ToLowerInvariant()
                            ErrorCategory   = 'DuplicateResourceIdentity'
                            ErrorCode       = 'DuplicateCanonicalIdentity'
                            Occurrences     = 1
                            Message         = $DuplicateMessage
                            RemediationHint = 'Microsoft Graph returned two resources with the same id for this resource type. Inspect the tenant objects, then start a new snapshot job; the rejected job is not retried.'
                        }
                    )
                    ErrorDetailsUnavailable     = $false
                    IsComplete                  = $false
                }
                $DuplicateAttemptManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $LastAttemptManifestFilePath -Encoding utf8
                if (Test-Path -Path $StagingFolder -ErrorAction SilentlyContinue) {
                    Remove-Item -Path $StagingFolder -Recurse -Force
                }
                if (Test-Path -Path $PendingJobFilePath -ErrorAction SilentlyContinue) {
                    Remove-Item -Path $PendingJobFilePath -Force
                }
                throw $DuplicateMessage
            }
            $CapturedResourceIdentities[$CanonicalIdentity] = $DisplayName
        }
        $StagedResources.Add([PSCustomObject]@{
                ResourceType        = $ResourceType
                DisplayName         = $DisplayName
                SafeFileNameSegment = $SafeFileNameSegment
                ResourceFolder      = $ResourceFolder
                ResourceFilePath    = $ResourceFilePath
                ResourceFilePathKey = $ResourceFilePath.ToLowerInvariant()
                DiscriminatorSeed   = if ([string]::IsNullOrWhiteSpace($ResourceIdentifier)) { $ResourceJson } else { $ResourceIdentifier }
                Json                = $ResourceJson
            })
    }

    $ResourceFilePathCounts = @{}
    foreach ($StagedResource in $StagedResources) {
        $ResourceFilePathCounts[$StagedResource.ResourceFilePathKey] = 1 + [int]$ResourceFilePathCounts[$StagedResource.ResourceFilePathKey]
    }

    # Allocate a unique path for every resource before writing anything: a discriminated name can
    # itself collide with another resource's natural name, so every candidate is checked against all
    # paths allocated so far. Allocation runs in a deterministic order to keep the outcome
    # independent of the order in which Graph returned the resources.
    $AllocatedFilePathKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($StagedResource in @($StagedResources | Sort-Object -Property ResourceFilePathKey, DiscriminatorSeed)) {
        $SafeFileNameSegment = $StagedResource.SafeFileNameSegment
        $ResourceFilePath = $StagedResource.ResourceFilePath
        $NeedsDiscriminator = $ResourceFilePathCounts[$StagedResource.ResourceFilePathKey] -gt 1
        $Attempt = 0

        while ($true) {
            if ($NeedsDiscriminator) {
                $DiscriminatorBytes = [System.Security.Cryptography.MD5]::HashData([System.Text.Encoding]::UTF8.GetBytes("$($StagedResource.DiscriminatorSeed)|$Attempt"))
                $Discriminator = ([System.BitConverter]::ToString($DiscriminatorBytes) -replace '-', '').Substring(0, 8)
                $SafeFileNameSegment = ConvertTo-EntraOpsSafeFilePathSegment -Segment "$($StagedResource.SafeFileNameSegment) $Discriminator" -FallbackName $Discriminator
                $ResourceFilePath = Join-Path -Path $StagedResource.ResourceFolder -ChildPath "$SafeFileNameSegment.json"
            }

            if ($AllocatedFilePathKeys.Add($ResourceFilePath.ToLowerInvariant())) {
                break
            }

            $NeedsDiscriminator = $true
            $Attempt++
            if ($Attempt -gt 100) {
                throw "Failed to allocate a unique file name for resource '$($StagedResource.DisplayName)' ($($StagedResource.ResourceType)) after $Attempt attempts. Re-run with -SnapshotResourceFileNaming ResourceId."
            }
        }

        if ($SafeFileNameSegment -ne $StagedResource.SafeFileNameSegment) {
            Write-Warning "Resource '$($StagedResource.DisplayName)' ($($StagedResource.ResourceType)) resolves to the same file name as another resource of this snapshot. It was saved as '$SafeFileNameSegment.json' instead. Use -SnapshotResourceFileNaming ResourceId for unique and rename-stable file names."
        }

        if (-not (Test-Path -LiteralPath $StagedResource.ResourceFolder)) {
            New-Item -Path $StagedResource.ResourceFolder -ItemType Directory -Force | Out-Null
        }
        $StagedResource.Json | Out-File -LiteralPath $ResourceFilePath -Encoding utf8
    }
    #endregion

    #region Build per-resource-type publication metadata
    # Snapshot resources arrive in an arbitrary order. Rebuild the count dictionary with ordinally
    # sorted keys so ConvertTo-Json emits byte-stable manifest metadata.
    $AttemptResourceTypeCounts = [ordered]@{}
    [string[]]$AttemptResourceTypeKeys = @($UnorderedAttemptResourceTypeCounts.Keys)
    [Array]::Sort($AttemptResourceTypeKeys, [StringComparer]::Ordinal)
    foreach ($ResourceType in $AttemptResourceTypeKeys) {
        $AttemptResourceTypeCounts[$ResourceType] = $UnorderedAttemptResourceTypeCounts[$ResourceType]
    }

    $PreviousManifest = $null
    if (Test-Path -Path $SnapshotManifestFilePath -ErrorAction SilentlyContinue) {
        try {
            $PreviousManifest = Get-Content -Path $SnapshotManifestFilePath -Raw | ConvertFrom-Json -Depth 10
        } catch {
            Write-Warning "The previous snapshot manifest could not be read. Stale resource files will still be preserved, but their source snapshot cannot be identified. Error: $_"
        }
    }
    $PreviousResourceTypeStates = @{}
    foreach ($PreviousState in @($PreviousManifest.ResourceTypeStates)) {
        if ($PreviousState.ResourceType) { $PreviousResourceTypeStates[$PreviousState.ResourceType.ToLowerInvariant()] = $PreviousState }
    }

    #endregion

    #region Promote the staged resource types and roll back all promoted directories if publication fails
    $PromotionRecords = [System.Collections.Generic.List[object]]::new()
    $RetainedResourceCounts = @{}
    try {
        New-Item -Path $BackupFolder -ItemType Directory -Force | Out-Null
        foreach ($ResourceType in $PublishableResourceTypes) {
            $SafeResourceType = ConvertTo-EntraOpsSafeFilePathSegment -Segment $ResourceType -FallbackName 'unknown'
            $StagedResourceFolder = Join-Path -Path $StagingFolder -ChildPath $SafeResourceType
            $PublishedResourceFolder = Join-Path -Path $ExportFolder -ChildPath $SafeResourceType
            $ResourceBackupFolder = Join-Path -Path $BackupFolder -ChildPath $SafeResourceType

            if (Test-Path -Path $PublishedResourceFolder -ErrorAction SilentlyContinue) {
                Move-Item -Path $PublishedResourceFolder -Destination $ResourceBackupFolder -Force
            }
            $PromotionRecords.Add([PSCustomObject]@{
                    PublishedFolder = $PublishedResourceFolder
                    BackupFolder    = $ResourceBackupFolder
                })
            if (Test-Path -Path $StagedResourceFolder -ErrorAction SilentlyContinue) {
                Move-Item -Path $StagedResourceFolder -Destination $PublishedResourceFolder -Force
            }

            if ($ResourceTypesWithRetainedResources -notcontains $ResourceType) { continue }
            $RetainedResourceCounts[$ResourceType] = 0
            if (-not (Test-Path -Path $ResourceBackupFolder -PathType Container -ErrorAction SilentlyContinue)) { continue }
            # Match retained files by resource identity, not file name: a resource that was renamed
            # in this attempt would otherwise be kept twice and fail the duplicate-identity check.
            $RetainedResourceIdentities = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            $PreviousResourceFiles = @(
                foreach ($PreviousFile in @(Get-ChildItem -LiteralPath $ResourceBackupFolder -Filter '*.json' -File -Recurse)) {
                    $PreviousResourceId = $null
                    # -AsHashtable: Graph emits empty property names in some payloads, which ConvertFrom-Json
                    # otherwise rejects, leaving the resource without an identity and retaining it twice.
                    try { $PreviousResourceId = [string](Get-Content -LiteralPath $PreviousFile.FullName -Raw | ConvertFrom-Json -AsHashtable -Depth 100).properties.Id } catch { $PreviousResourceId = $null }
                    $HasPreviousResourceId = -not [string]::IsNullOrWhiteSpace($PreviousResourceId)
                    $ExpectedIdFileName = if ($HasPreviousResourceId) {
                        ConvertTo-EntraOpsSafeFilePathSegment -Segment $PreviousResourceId -FallbackName 'Unnamed'
                    } else { $null }
                    [PSCustomObject]@{
                        File          = $PreviousFile
                        ResourceId    = $PreviousResourceId
                        HasResourceId = $HasPreviousResourceId
                        IsIdNamed     = $HasPreviousResourceId -and [System.IO.Path]::GetFileNameWithoutExtension($PreviousFile.Name).Equals($ExpectedIdFileName, [System.StringComparison]::OrdinalIgnoreCase)
                    }
                }
            )
            foreach ($PreviousResourceFile in @($PreviousResourceFiles | Sort-Object @{ Expression = { if ($_.IsIdNamed) { 0 } else { 1 } } }, @{ Expression = { $_.File.FullName } })) {
                $PreviousFile = $PreviousResourceFile.File
                $PreviousResourceId = $PreviousResourceFile.ResourceId
                $HasPreviousResourceId = $PreviousResourceFile.HasResourceId
                if ($HasPreviousResourceId) {
                    $PreviousIdentity = "$ResourceType|$($PreviousResourceId.Trim().ToLowerInvariant())"
                    if ($CapturedResourceIdentities.ContainsKey($PreviousIdentity) -or -not $RetainedResourceIdentities.Add($PreviousIdentity)) { continue }
                }
                $RelativePath = $PreviousFile.FullName.Substring($ResourceBackupFolder.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
                $RetainedFilePath = Join-Path -Path $PublishedResourceFolder -ChildPath $RelativePath
                if (Test-Path -LiteralPath $RetainedFilePath) {
                    # Without an identity the same path is the only evidence of the same resource.
                    if (-not $HasPreviousResourceId) { continue }
                    # A different resource now owns this display-name path; keep the failed one
                    # under a discriminated name derived from its stable identity.
                    $DiscriminatorBytes = [System.Security.Cryptography.MD5]::HashData([System.Text.Encoding]::UTF8.GetBytes("$PreviousResourceId|retained"))
                    $Discriminator = ([System.BitConverter]::ToString($DiscriminatorBytes) -replace '-', '').Substring(0, 8)
                    $RetainedFileName = ConvertTo-EntraOpsSafeFilePathSegment -Segment "$([System.IO.Path]::GetFileNameWithoutExtension($RetainedFilePath)) $Discriminator" -FallbackName $Discriminator
                    $RetainedFilePath = Join-Path -Path (Split-Path -Path $RetainedFilePath -Parent) -ChildPath "$RetainedFileName.json"
                    if (Test-Path -LiteralPath $RetainedFilePath) { continue }
                    Write-Warning "Retained resource '$PreviousResourceId' ($ResourceType) no longer owns its file name; it was kept as '$RetainedFileName.json'."
                }
                $RetainedFolder = Split-Path -Path $RetainedFilePath -Parent
                if (-not (Test-Path -LiteralPath $RetainedFolder)) { New-Item -Path $RetainedFolder -ItemType Directory -Force | Out-Null }
                Copy-Item -LiteralPath $PreviousFile.FullName -Destination $RetainedFilePath -Force
                $RetainedResourceCounts[$ResourceType]++
            }
            if ($RetainedResourceCounts[$ResourceType] -gt 0) {
                Write-Warning "Resource type '$ResourceType' was published with export errors. $($RetainedResourceCounts[$ResourceType]) resource file(s) the snapshot job did not return were retained from the previously published snapshot."
            }
        }

        # Validation scans every published resource folder, including preserved-stale and no-longer
        # configured types. Heal only the unambiguous naming migration case: one exact ID-named file
        # plus one or more legacy display-name aliases for the same canonical resource identity.
        $PublishedIdentityFiles = @{}
        foreach ($PublishedFile in @(Get-ChildItem -LiteralPath $ExportFolder -Filter '*.json' -File -Recurse | Where-Object { $_.FullName -notmatch '[\\/]\.(?:staging|backup)-' })) {
            $PublishedResource = $null
            try { $PublishedResource = Get-Content -LiteralPath $PublishedFile.FullName -Raw | ConvertFrom-Json -AsHashtable -Depth 100 } catch { continue }
            $PublishedResourceType = ([string]$PublishedResource.resourceType).Trim().ToLowerInvariant()
            $PublishedResourceId = ([string]$PublishedResource.properties.Id).Trim().ToLowerInvariant()
            if ([string]::IsNullOrWhiteSpace($PublishedResourceType) -or [string]::IsNullOrWhiteSpace($PublishedResourceId)) { continue }
            $PublishedIdentity = "$PublishedResourceType|$PublishedResourceId"
            $ExpectedIdFileName = ConvertTo-EntraOpsSafeFilePathSegment -Segment $PublishedResourceId -FallbackName 'Unnamed'
            if (-not $PublishedIdentityFiles.ContainsKey($PublishedIdentity)) { $PublishedIdentityFiles[$PublishedIdentity] = [System.Collections.Generic.List[object]]::new() }
            $PublishedIdentityFiles[$PublishedIdentity].Add([PSCustomObject]@{
                    File      = $PublishedFile
                    IsIdNamed = [System.IO.Path]::GetFileNameWithoutExtension($PublishedFile.Name).Equals($ExpectedIdFileName, [System.StringComparison]::OrdinalIgnoreCase)
                })
        }
        foreach ($PublishedIdentity in @($PublishedIdentityFiles.Keys | Sort-Object)) {
            $IdentityFiles = @($PublishedIdentityFiles[$PublishedIdentity])
            if ($IdentityFiles.Count -lt 2) { continue }
            $IdNamedFiles = @($IdentityFiles | Where-Object { $_.IsIdNamed })
            if ($IdNamedFiles.Count -ne 1) { continue }
            foreach ($LegacyFile in @($IdentityFiles | Where-Object { -not $_.IsIdNamed })) {
                Remove-Item -LiteralPath $LegacyFile.File.FullName -Force
                Write-Warning "Removed legacy Tenant Governance alias '$($LegacyFile.File.Name)' for '$PublishedIdentity'; the ID-named copy was retained."
            }
        }
    } catch {
        for ($Index = $PromotionRecords.Count - 1; $Index -ge 0; $Index--) {
            $PromotionRecord = $PromotionRecords[$Index]
            if (Test-Path -Path $PromotionRecord.PublishedFolder -ErrorAction SilentlyContinue) {
                Remove-Item -Path $PromotionRecord.PublishedFolder -Recurse -Force
            }
            if (Test-Path -Path $PromotionRecord.BackupFolder -ErrorAction SilentlyContinue) {
                Move-Item -Path $PromotionRecord.BackupFolder -Destination $PromotionRecord.PublishedFolder -Force
            }
        }
        foreach ($TemporaryFolder in @($StagingFolder, $BackupFolder)) {
            if (Test-Path -Path $TemporaryFolder -ErrorAction SilentlyContinue) {
                Remove-Item -Path $TemporaryFolder -Recurse -Force
            }
        }
        throw "Failed to publish Tenant Governance snapshot resources. Existing resource folders were restored and pending job state was preserved. Error: $_"
    }
    #endregion

    #region Build published counts and per-resource-type states after successful promotion
    $PublishedResourceTypeCounts = [ordered]@{}
    foreach ($ResourceType in $RequestedResourceTypes) {
        $PublishedResourceFolder = Join-Path -Path $ExportFolder -ChildPath $ResourceType
        $PublishedResourceTypeCounts[$ResourceType] = if (Test-Path -Path $PublishedResourceFolder -PathType Container) {
            @(Get-ChildItem -Path $PublishedResourceFolder -Filter '*.json' -File -Recurse -ErrorAction SilentlyContinue).Count
        } else { 0 }
    }

    $ResourceTypeStates = @(
        foreach ($ResourceType in $RequestedResourceTypes) {
            $IsStale = $StaleResourceTypes -contains $ResourceType
            $IsRetained = $ResourceTypesWithRetainedResources -contains $ResourceType
            $PreviousState = $PreviousResourceTypeStates[$ResourceType]
            $PreviousCompleteSnapshotId = if ($PreviousManifest.IsComplete -eq $true) { $PreviousManifest.SnapshotId } else { $null }
            $PublishedSnapshotId = if ($IsStale) {
                if ($PreviousState.PublishedSnapshotId) { $PreviousState.PublishedSnapshotId } else { $PreviousCompleteSnapshotId }
            } else { $Snapshot.SnapshotId }
            [PSCustomObject][ordered]@{
                ResourceType           = $ResourceType
                Status                 = if ($IsStale) { 'PreservedStale' } elseif ($IsRetained) { 'PublishedWithErrors' } else { 'Published' }
                CapturedResourceCount  = if ($AttemptResourceTypeCounts.Contains($ResourceType)) { $AttemptResourceTypeCounts[$ResourceType] } else { 0 }
                AttemptResourceCount   = if ($AttemptResourceTypeCounts.Contains($ResourceType)) { $AttemptResourceTypeCounts[$ResourceType] } else { 0 }
                PublishedResourceCount = $PublishedResourceTypeCounts[$ResourceType]
                RetainedResourceCount  = if ($RetainedResourceCounts.ContainsKey($ResourceType)) { [int]$RetainedResourceCounts[$ResourceType] } else { 0 }
                PublishedSnapshotId    = $PublishedSnapshotId
                PublishedSource        = if ($PublishedSnapshotId) { 'Snapshot' } else { 'UnknownLegacySnapshot' }
                AttemptSnapshotId      = $Snapshot.SnapshotId
                Diagnostics            = @($SnapshotDiagnostics | Where-Object { $_.ResourceType -eq $ResourceType })
            }
        }
    )
    #endregion

    #region Build the command summary and persisted snapshot manifests
    $SnapshotSummary = [PSCustomObject]@{
        TenantId            = $Global:TenantIdContext
        TenantName          = $Global:TenantNameContext
        SnapshotId          = $Snapshot.SnapshotId
        SnapshotDisplayName = $Snapshot.DisplayName
        SnapshotJobStatus   = $SnapshotJobStatus
        CapturedDateTime    = $Snapshot.CreatedDateTime
        ResourcesToInclude  = $Snapshot.ResourcesToInclude
        ResourceCount       = $Snapshot.ResourceCount
    }

    $SnapshotManifest = [ordered]@{
        SnapshotId                       = $Snapshot.SnapshotId
        SnapshotDisplayName              = $Snapshot.DisplayName
        SnapshotJobStatus                = $SnapshotJobStatus
        CapturedDateTime                 = $Snapshot.CreatedDateTime
        ResourcesToInclude               = @($Snapshot.ResourcesToInclude)
        CapturedResourceCount            = $Snapshot.ResourceCount
        AttemptResourceTypeCounts        = $AttemptResourceTypeCounts
        PublishedResourceTypeCounts      = $PublishedResourceTypeCounts
        ResourceTypeFileCounts           = $PublishedResourceTypeCounts
        PublishedResourceTypes           = @($PublishableResourceTypes)
        PublishedWithErrorsResourceTypes = @($ResourceTypesWithRetainedResources)
        StaleResourceTypes               = @($StaleResourceTypes)
        ResourceTypeStates               = @($ResourceTypeStates)
        Diagnostics                      = @($SnapshotDiagnostics)
        ErrorDetailsUnavailable          = $ParsedErrorDetails.Count -eq 0 -and $SnapshotJobStatus -eq 'partiallySuccessful'
        IsComplete                       = $SnapshotJobStatus -in @('completed', 'succeeded') -and $ResourceTypesWithErrors.Count -eq 0
    }
    $SnapshotManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $LastAttemptManifestFilePath -Encoding utf8
    $SnapshotManifest | ConvertTo-Json -Depth 10 | Set-Content -Path $SnapshotManifestFilePath -Encoding utf8

    foreach ($TemporaryFolder in @($StagingFolder, $BackupFolder)) {
        if (Test-Path -Path $TemporaryFolder -ErrorAction SilentlyContinue) {
            Remove-Item -Path $TemporaryFolder -Recurse -Force
        }
    }
    if (Test-Path -Path $PendingJobFilePath -ErrorAction SilentlyContinue) {
        Remove-Item -Path $PendingJobFilePath -Force
    }

    if ($SnapshotManifest.IsComplete) {
        Write-Output "Tenant Governance snapshot completed. $($Snapshot.ResourceCount) resource(s) captured and saved under $ExportFolder. Commit and push these files to track this point-in-time in Git history."
    } else {
        Write-Warning "Tenant Governance snapshot job $($Snapshot.SnapshotId) completed as '$SnapshotJobStatus'. $($PublishableResourceTypes.Count) resource type(s) were published ($($ResourceTypesWithRetainedResources.Count) of them with per-resource export errors) and $($StaleResourceTypes.Count) type(s) with Graph errors were preserved as stale."
    }
    Write-Output ($SnapshotSummary | Format-List | Out-String)
    #endregion

    return $SnapshotSummary
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDNcWotJU1MhLCk
# AzN+hf+yVmrOLRwlgvFU7ZZLJ+oee6CCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# 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
# ARUwLwYJKoZIhvcNAQkEMSIEIMMS5DjjGGuw8faV3NnUezRS/pUwA/KzsLeOe5Rl
# wLw7MA0GCSqGSIb3DQEBAQUABIIBgG0Tuhrg7Cqxvgz/+CfA6fWdFwZr+C/ni+YM
# DzMThQYquD1Z+MDIUKpqdDyHa3GA/CoGS3Y+kDMSNIz0ujLBlHiKRfbAnZEyib3k
# olwoEtmUCdhLak3qZi+w7bIIySlXw/5QgB1ozfy0TiNK14Ieq9QtmG7bWQz85BnZ
# YRvFGqI6vE/qyzt4/pZXLqubZYsboB/N8fxdI3H2x8Iw1qANU59f53M6/IgQ8FhI
# F+aBAs8pj1x6RwPSLyf2h76RfpJjcAbweI8yiqrpcBIA901bxY9dXmmO/QvXslsa
# A7nDxVboPfZ01nYniZ04fZkw2C617NMoXyeiBrrSYie1SX2v6unJyH+a8fULqePx
# du8iXIgo0b/2XUwl8ONFpjunxAoj8hqIPmgwVxkXVQWKXVP5iO2h/CRR5i4x2CsB
# dg/xVovUqE1rF3TuORaX8n67G8E0Ks2AGTEyEVzxGFx0/Ad2CquuSm0oUq8N+by7
# HdLkqrAuqT/gcnVoT8GT5VEktjml3aGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMTMwOTQyMTJaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDD+yFL2uIyI/T4hUCkvKLbexAMUS0+bpv1U
# LVdxMPSfdDjhorYF6uG9JGTOdWKUxrAwgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAcAoUWABW4VLcw48/aypjX9Wo7uewPOlP72cxzWkPzZRIbW/4
# MRa+jGaSzUYvueRdMOPvkpRFeD/pbGnli5+ofka0miybFdsIZM2Krsa6sQ2aTmGq
# lj0uktD+0wr+czemr6rxxuNlhaWJElOY6NpLMARIk3G9rfnLXmjYspCtLLJt9Ix5
# a6Q4rUxbv+sVpdwjv/HzVx158c/9lcr8fxxgCQKiRQOgDYtVjm0ezFOLhVkM5MPy
# TyNToTRQLN5hEcXljfzURen3fKjZjVZdLow386fMIR6E0HI9WbPw9mgVMAHOyM6G
# IerQT7dXJ4HLrX76YS07M/A8pOmwUYGUVZNj6K6nxZLIbYW+PI1lAwKO5AFVjCQF
# dkqiU0jGLUyMkNnRK/fwdVZotm+aLgxyv9ben/6zhnwvLSLWyjY8A2j/jSEHw8Vd
# wN0H5l+28vKmlSiH4evIEY8EME+kEePjxNpVkNBv7q63ytU7tMPNhu9KpRCmAApa
# OwhNtKhkyTuFXeMtXD6p+kBqEnU5aHD0+UnH4ZmWMqVtDEcopK4dhvwwTHQsx/Av
# Nm2eRzYIkLHyJQDwnEi0oCtD5hWP/c+siWye1muzSoADE7ACmzK7syM0T2RWLzAL
# 86aLx626tDfJvPB33Naa1quFWXcC5ZeYyTVUfIaNmvoPJE5YkXovpj8vCsI=
# SIG # End signature block