Private/Collect/Get-PpaRetention.ps1

# Get-PpaRetention.ps1 - collector for section 03 (Retention & Records).
# Reads Get-RetentionCompliancePolicy (scope + locations), Get-RetentionComplianceRule
# (labels + auto-apply condition), Get-AdaptiveScope (count) and Get-ComplianceTag
# (the retention-label friendly-name inventory) - all read-only. Projects to
# client-safe metadata: policy/label NAMES, scope type, location TYPES, and whether an
# auto-apply condition exists (never the condition contents/query).
# ASCII-only source. Depends on Invoke-PpaReadCmdlet.ps1.
#
# Pre-publish Part 7: on a live tenant a label-publishing rule carries an
# auto-GUID .Name; the published label lives in PublishComplianceTag (visible-to-
# users) or ApplyComplianceTag (auto-applied) - and either may ITSELF be a GUID.
# Get-PpaRetentionRuleLabel prefers the tag reference over the rule name and
# resolves it through the Get-ComplianceTag inventory; anything unresolvable
# passes through verbatim (orphan fallback), so hand-authored name-based fixtures
# render exactly as before. NOTE: the auto-apply property linkage remains
# best-effort until confirmed against a live tenant.

Set-StrictMode -Off

function Get-PpaRetentionRuleLabel {
    # Display label for ONE retention rule: prefer the published/applied tag over
    # the (often auto-GUID) rule name, then resolve through the Get-ComplianceTag
    # inventory. Part 9 (confirmed against live TEST data): the tag reference is a
    # COMPOUND string "<ImmutableId>,<Name>" - and the rule keys on the tag's
    # ImmutableId, NOT its Guid, so the map's ImmutableId key is load-bearing.
    # Resolution order:
    # 1. whole ref as a map key (protects inventory tag names that carry commas)
    # 2. split on ',' (trimmed) - first segment that resolves wins
    # 3. first non-GUID-shaped segment verbatim (worst case shows the name tail,
    # never the raw id)
    # 4. first segment verbatim (true orphan - still shows SOMETHING, never blank)
    param($Rule, $TagMap)
    $ref = if ($Rule.PublishComplianceTag) { [string]$Rule.PublishComplianceTag }
           elseif ($Rule.ApplyComplianceTag) { [string]$Rule.ApplyComplianceTag }
           elseif ($Rule.ComplianceTagProperty) { [string]$Rule.ComplianceTagProperty }
           else { [string]$Rule.Name }
    if ([string]::IsNullOrEmpty($ref)) { return $ref }
    if ($TagMap.ContainsKey($ref)) { return $TagMap[$ref] }
    $segments = @($ref -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    foreach ($seg in $segments) {
        if ($TagMap.ContainsKey($seg)) { return $TagMap[$seg] }
    }
    foreach ($seg in $segments) {
        if ($seg -notmatch '^[0-9a-fA-F-]{36}$') { return $seg }
    }
    if ($segments.Count -gt 0) { return $segments[0] }
    return $ref
}

function Get-PpaRetention {
    [CmdletBinding()]
    param()

    $rawPols   = Invoke-PpaReadCmdlet -Name 'Get-RetentionCompliancePolicy'
    $rawRules  = Invoke-PpaReadCmdlet -Name 'Get-RetentionComplianceRule'
    $rawScopes = Invoke-PpaReadCmdlet -Name 'Get-AdaptiveScope'
    # Part 7: the friendly-name inventory behind Get-PpaRetentionRuleLabel. Folded
    # into the outcome below like every other read - an unreadable tag list
    # degrades visibility honestly rather than silently showing raw references.
    $rawTags   = Invoke-PpaReadCmdlet -Name 'Get-ComplianceTag'

    # Tag resolution map: Guid, ImmutableId (when present) and Name all key the
    # friendly tag Name. PS hashtable literals compare string keys
    # case-insensitively, which covers GUID casing differences for free.
    $tagMap = @{}
    foreach ($t in @($rawTags.Data)) {
        if ($null -eq $t) { continue }
        foreach ($k in @([string]$t.Guid, [string]$t.ImmutableId, [string]$t.Name)) {
            if (-not [string]::IsNullOrEmpty($k) -and -not $tagMap.ContainsKey($k)) { $tagMap[$k] = [string]$t.Name }
        }
    }

    $policyItems = foreach ($p in @($rawPols.Data)) {
        $locs = New-Object System.Collections.Generic.List[string]
        if (@($p.SharePointLocation).Count   -gt 0) { $locs.Add('SharePoint') }
        if (@($p.ExchangeLocation).Count     -gt 0) { $locs.Add('Exchange') }
        if (@($p.ModernGroupLocation).Count  -gt 0) { $locs.Add('Groups') }
        if (@($p.OneDriveLocation).Count     -gt 0) { $locs.Add('OneDrive') }
        $ruleLabels = @($rawRules.Data | Where-Object { $_.Policy -eq $p.Guid -or $_.ParentPolicyName -eq $p.Name } | ForEach-Object { Get-PpaRetentionRuleLabel -Rule $_ -TagMap $tagMap })
        [pscustomobject]@{
            name      = [string]$p.Name
            guid      = Get-PpaOptionalGuid $p
            adaptive  = (@($p.AdaptiveScopeLocation).Count -gt 0)
            locations = @($locs)
            # Part D matrix grounding (documented-only shape, additive; the token
            # array above stays the analyzer contract). Includes the documented
            # Teams retention locations not surfaced in the legacy tokens.
            locationScope = [pscustomobject]@{
                exchange     = (Get-PpaLocationScopeToken $p.ExchangeLocation)
                sharePoint   = (Get-PpaLocationScopeToken $p.SharePointLocation)
                oneDrive     = (Get-PpaLocationScopeToken $p.OneDriveLocation)
                groups       = (Get-PpaLocationScopeToken $p.ModernGroupLocation)
                teamsChannel = (Get-PpaLocationScopeToken $p.TeamsChannelLocation)
                teamsChat    = (Get-PpaLocationScopeToken $p.TeamsChatLocation)
            }
            labels    = @($ruleLabels)
        }
    }

    $labelItems = foreach ($r in @($rawRules.Data)) {
        $auto = (-not [string]::IsNullOrEmpty([string]$r.ContentMatchQuery)) -or (@($r.ContentContainsSensitiveInformation).Count -gt 0)
        [pscustomobject]@{ name = (Get-PpaRetentionRuleLabel -Rule $r -TagMap $tagMap); guid = Get-PpaOptionalGuid $r; autoApply = [bool]$auto }
    }

    return [pscustomobject]@{
        outcome        = Resolve-PpaCollectorOutcome -ReadStatuses @($rawPols.Status, $rawRules.Status, $rawScopes.Status, $rawTags.Status) -ItemCount (@($policyItems).Count + @($labelItems).Count + @($rawScopes.Data).Count)
        policies       = [pscustomobject]@{ status = $rawPols.Status;  error = $rawPols.Error;  items = @($policyItems) }
        labels         = [pscustomobject]@{ status = $rawRules.Status; error = $rawRules.Error; items = @($labelItems) }
        adaptiveScopes = [pscustomobject]@{ status = $rawScopes.Status; count = @($rawScopes.Data).Count }
    }
}