Private/Compliance/Invoke-GovernanceReconcile.ps1

function Invoke-GovernanceReconcile {
    <#
        .SYNOPSIS
        Unified compliance loop. For every governed resource it checks live ADO
        state against the resolved desired state.

        In Apply mode: deviations are corrected immediately. A finding is only
        recorded when a fix FAILS. Successfully applied changes are NOT findings.

        In Audit / WhatIf mode: every deviation is recorded as a finding and
        reported. No changes are made.

        Mode:
          Audit - check + report only. Read-only. Findings signal CI failure.
          Apply - check + fix. Findings = things that could not be fixed.
          WhatIf - check + report what would be fixed. No changes made.

        Prune (opt-in, never on by default): in Apply mode, resources that exist
        in ADO but not in the resolved model are DELETED instead of reported —
        orphan teams, orphan area paths (work items reparented to the parent
        node), orphan repos (soft-deleted to the recycle bin), and extra group
        members. scope:future placeholders, the project default team/repo, and
        iteration paths (ADR-005) are never pruned. In WhatIf mode prune shows
        what would be deleted; Audit never deletes regardless.

        Returns a string array of remaining findings. Empty = fully compliant.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][object]$Resolved,
        [Parameter(Mandatory)][string]$OrgUrl,
        [ValidateSet('Audit', 'Apply', 'WhatIf')]
        [string]$Mode = 'Audit',
        [string]$ReportPath = '',
        [hashtable]$TeamIds = @{},   # codePath -> ADO GUID; mutated in-place for caller to persist
        [switch]$Prune               # delete orphans (Apply) / report would-delete (WhatIf)
    )

    $findings = [System.Collections.Generic.List[string]]::new()
    # Target project comes from the manifest project block; fall back to the
    # program name for resolved files built before the project declaration existed.
    $project  = if ($Resolved.project -and $Resolved.project.name) { $Resolved.project.name } else { $Resolved.program }
    $doFix    = ($Mode -eq 'Apply')

    # output helpers
    $rOk      = { param($m) Write-Host " [ok] $m" -ForegroundColor Green }
    $rCreated = { param($m) Write-Host " [+] $m [created]" -ForegroundColor Yellow }
    $rFixed   = { param($m) Write-Host " [~] $m [corrected]" -ForegroundColor Yellow }
    $rDeleted = { param($m) Write-Host " [-] $m [deleted]" -ForegroundColor Yellow }
    $rWould   = { param($m) Write-Host " [NON-COMPLIANT] $m (dry-run: no changes made)" -ForegroundColor Cyan }
    $rMissing = { param($m) Write-Host " [MISSING] $m" -ForegroundColor Red }
    $rDrift   = { param($m) Write-Host " [DRIFT] $m" -ForegroundColor Red }
    $rOrphan  = { param($m) Write-Host " [AUDIT EXCEPTION] $m (exists in ADO but not in config)" -ForegroundColor Magenta }
    $rError   = { param($m) Write-Host " [ERROR] $m" -ForegroundColor Red }

    # ── 1. Area paths ─────────────────────────────────────────────────────────
    Write-Host "`n--- Area paths ---" -ForegroundColor Cyan

    $desiredAreas = @($Resolved.areaPaths | Where-Object { $_.scope -ne 'future' })
    $desiredPaths = @($desiredAreas | ForEach-Object { $_.path }) |
                    Sort-Object { ($_ -split '\\').Count }

    $liveAreas = @{}
    foreach ($path in $desiredPaths) {
        $exists = Test-AdoAreaPath -OrgUrl $OrgUrl -Project $project -ResolvedPath $path
        if ($exists) {
            $liveAreas[$path] = $true
            & $rOk $path
        } else {
            if ($doFix) {
                try {
                    New-AdoAreaPath -OrgUrl $OrgUrl -Project $project -ResolvedPath $path
                    & $rCreated "area path: $path"
                    $liveAreas[$path] = $true
                } catch {
                    $findings.Add("ERROR creating area path '$path': $_")
                    & $rError "create area path '$path': $_"
                }
            } else {
                $findings.Add("MISSING area path: $path")
                if ($Mode -eq 'WhatIf') { & $rWould "create area path: $path" }
                else                     { & $rMissing "area path: $path" }
            }
        }
    }

    # Audit exceptions: best-effort bulk fetch for extra paths.
    # Orphans are matched against EVERY path in the resolved model, including
    # scope:future placeholders — future products are invisible to apply/audit
    # and must never be flagged (or pruned) as orphans.
    try {
        $modelPaths = [System.Collections.Generic.HashSet[string]]::new(
            [string[]]@($Resolved.areaPaths | ForEach-Object { $_.path }),
            [System.StringComparer]::OrdinalIgnoreCase)
        $bulkAreas = Get-AdoAreaPathSubtree -OrgUrl $OrgUrl -Project $project
        if ($bulkAreas.Count -gt 1) {
            # Deepest-first so pruning a subtree removes children before parents.
            $orphanAreas = @($bulkAreas.Keys | Where-Object { -not $modelPaths.Contains($_) } |
                Sort-Object { ($_ -split '\\').Count } -Descending)
            foreach ($livePath in $orphanAreas) {
                if ($Prune -and $doFix) {
                    try {
                        Remove-AdoAreaPath -OrgUrl $OrgUrl -Project $project -ResolvedPath $livePath
                        & $rDeleted "area path: $livePath"
                    } catch {
                        $findings.Add("ERROR deleting orphan area path '$livePath': $_")
                        & $rError "delete area path '$livePath': $_"
                    }
                } else {
                    $findings.Add("AUDIT EXCEPTION area path: $livePath")
                    if ($Prune -and $Mode -eq 'WhatIf') { & $rWould "delete orphan area path: $livePath" }
                    else                                { & $rOrphan "area path: $livePath" }
                }
            }
        }
    } catch { Write-Verbose "Area path audit exception check skipped: $_" }

    # ── 2. Iteration paths — must exist before teams are configured ─────────────
    if ($Resolved.iterations -and $Resolved.iterations.paths) {
        Write-Host "`n--- Iteration paths ---" -ForegroundColor Cyan

        # Compliance rule: we only check that the DESIRED paths EXIST.
        # Extra iteration paths in ADO that match the governance naming pattern
        # (\Program\YYYY\S[n]\S[n]-W[n]) are NEVER flagged as audit exceptions —
        # old sprints are expected to accumulate and are compliant by definition.

        $iterPaths = @($Resolved.iterations.paths | Sort-Object { ($_.path -split '\\').Count })
        foreach ($iter in $iterPaths) {
            $exists = Test-AdoIterationPath -OrgUrl $OrgUrl -Project $project -ResolvedPath $iter.path
            if ($exists) {
                & $rOk "iter: $($iter.path)"
            } else {
                if ($doFix) {
                    try {
                        New-AdoIterationPath -OrgUrl $OrgUrl -Project $project `
                            -ResolvedPath $iter.path -StartDate $iter.startDate -EndDate $iter.endDate
                        & $rCreated "iter: $($iter.path)"
                    } catch {
                        $findings.Add("ERROR creating iteration '$($iter.path)': $_")
                        & $rError "create iteration '$($iter.path)': $_"
                    }
                } else {
                    $findings.Add("MISSING iteration: $($iter.path)")
                    if ($Mode -eq 'WhatIf') { & $rWould "create iteration: $($iter.path)" }
                    else                     { & $rMissing "iteration: $($iter.path)" }
                }
            }
        }
    }

    # ── 3. Teams ──────────────────────────────────────────────────────────────
    Write-Host "`n--- Teams ---" -ForegroundColor Cyan

    # $liveTeams : name -> GUID (from ADO)
    # $reverseIds : GUID -> name (reverse index for GUID-based lookup)
    $liveTeams  = Get-AdoTeamList -OrgUrl $OrgUrl -Project $project
    $reverseIds = @{}
    foreach ($k in $liveTeams.Keys) { if ($liveTeams[$k]) { $reverseIds[$liveTeams[$k]] = $k } }

    $liveTeamNames = [System.Collections.Generic.HashSet[string]]::new(
        [string[]]@($liveTeams.Keys | Where-Object { $_ }),
        [System.StringComparer]::OrdinalIgnoreCase)

    $desiredTeams = @($Resolved.teams | Where-Object {
        $_.name -ne $project -and $_.scope -ne 'future' })

    foreach ($team in $desiredTeams) {
        $existingId   = $null
        $existingName = $null

        # Phase 1: find by current desired name (fast path)
        if ($liveTeams.ContainsKey($team.name)) {
            $existingId   = $liveTeams[$team.name]
            $existingName = $team.name
        }
        # Phase 2: find by stored GUID (survives a rename in the config)
        elseif ($TeamIds.ContainsKey($team.codePath)) {
            $storedId = $TeamIds[$team.codePath]
            if ($storedId -and $reverseIds.ContainsKey($storedId)) {
                $existingId   = $storedId
                $existingName = $reverseIds[$storedId]   # current ADO name (old name)
            }
        }

        $exists = $null -ne $existingId

        if (-not $exists) {
            # Team not found by name or GUID — create it
            if ($doFix) {
                try {
                    $newId = New-AdoTeam -OrgUrl $OrgUrl -Project $project -Name $team.name
                    & $rCreated "team: $($team.name)"
                    # Initialise iteration defaults immediately so area config PATCH succeeds
                    Initialize-AdoTeamDefaults -OrgUrl $OrgUrl -Project $project -Team $team.name
                    $TeamIds[$team.codePath] = $newId
                    $liveTeams[$team.name]   = $newId
                    $liveTeamNames.Add($team.name) | Out-Null
                    $exists = $true; $existingId = $newId; $existingName = $team.name
                } catch {
                    $findings.Add("ERROR creating team '$($team.name)': $_")
                    & $rError "create team '$($team.name)': $_"
                }
            } else {
                $findings.Add("MISSING team: $($team.name)")
                if ($Mode -eq 'WhatIf') { & $rWould "create team: $($team.name)" }
                else                     { & $rMissing "team: $($team.name)" }
            }
        }
        elseif ($existingName -ne $team.name) {
            # Found by GUID but name doesn't match — rename
            if ($doFix) {
                try {
                    Set-AdoTeamName -OrgUrl $OrgUrl -Project $project -TeamId $existingId -NewName $team.name
                    & $rFixed "team: renamed '$existingName' -> '$($team.name)'"
                    $liveTeams.Remove($existingName)
                    $liveTeams[$team.name] = $existingId
                    $liveTeamNames.Remove($existingName) | Out-Null
                    $liveTeamNames.Add($team.name) | Out-Null
                    $TeamIds[$team.codePath] = $existingId
                    $existingName = $team.name
                } catch {
                    $findings.Add("ERROR renaming team '$existingName' to '$($team.name)': $_")
                    & $rError "rename team '$existingName' to '$($team.name)': $_"
                }
            } else {
                $findings.Add("DRIFT team '$($team.codePath)': name should be '$($team.name)' but ADO has '$existingName'")
                if ($Mode -eq 'WhatIf') { & $rWould "rename team '$existingName' -> '$($team.name)'" }
                else                     { & $rDrift "team '$($team.codePath)': name '$existingName' should be '$($team.name)'" }
            }
        }
        else {
            # Found by name — record GUID for future renames
            $TeamIds[$team.codePath] = $existingId
        }

        if ($exists -and $existingName -eq $team.name) {
            try {
                $liveConfig = Get-AdoTeamAreaConfig -OrgUrl $OrgUrl -Project $project -Team $team.name
                $liveSet    = @{}
                foreach ($v in @($liveConfig.values)) {
                    $liveSet["\$($v.value)"] = [bool]$v.includeChildren
                }

                $desired      = @($team.areaConfig | Where-Object { $_ })
                $driftDetails = [System.Collections.Generic.List[string]]::new()

                foreach ($entry in $desired) {
                    if (-not $liveSet.ContainsKey($entry.path)) {
                        $driftDetails.Add("DRIFT team '$($team.name)': area path '$($entry.path)' not configured")
                    } elseif ($liveSet[$entry.path] -ne [bool]$entry.includeSubAreas) {
                        $driftDetails.Add("DRIFT team '$($team.name)': '$($entry.path)' includeSubAreas should be $($entry.includeSubAreas) but is $($liveSet[$entry.path])")
                    }
                }
                foreach ($livePath in $liveSet.Keys) {
                    if ($livePath -notin ($desired | ForEach-Object { $_.path })) {
                        $driftDetails.Add("DRIFT team '$($team.name)': unexpected area path '$livePath' in config")
                    }
                }

                if ($driftDetails.Count -eq 0) {
                    & $rOk "team: $($team.name) (area config)"
                } else {
                    if ($doFix) {
                        try {
                            Set-AdoTeamAreaConfig -OrgUrl $OrgUrl -Project $project -Team $team.name -AreaConfig $desired
                            & $rFixed "team: $($team.name) area config"
                        } catch {
                            foreach ($d in $driftDetails) { $findings.Add($d) }
                            $findings.Add("ERROR setting area config '$($team.name)': $_")
                            & $rError "set area config '$($team.name)': $_"
                        }
                    } else {
                        foreach ($d in $driftDetails) { $findings.Add($d) }
                        if ($Mode -eq 'WhatIf') { & $rWould "correct area config for team: $($team.name)" }
                        else                     { & $rDrift "team: $($team.name) area config wrong" }
                    }
                }
            } catch { & $rError "read area config '$($team.name)': $_" }
        }
    }

    # Orphan teams: exempt the project default team and scope:future teams —
    # future products are invisible to apply/audit and must never be flagged
    # (or pruned) as orphans.
    $futureTeamNames    = @($Resolved.teams | Where-Object { $_.scope -eq 'future' } | ForEach-Object { $_.name })
    $desiredTeamNameSet = [System.Collections.Generic.HashSet[string]]::new(
        [string[]]@(@($desiredTeams | ForEach-Object { $_.name }) + $futureTeamNames + @($project) + @("$project Team")),
        [System.StringComparer]::OrdinalIgnoreCase)
    foreach ($liveName in ($liveTeamNames | Sort-Object)) {
        if (-not $desiredTeamNameSet.Contains($liveName)) {
            if ($Prune -and $doFix) {
                try {
                    Remove-AdoTeam -OrgUrl $OrgUrl -Project $project -TeamId $liveTeams[$liveName]
                    & $rDeleted "team: $liveName"
                } catch {
                    $findings.Add("ERROR deleting orphan team '$liveName': $_")
                    & $rError "delete team '$liveName': $_"
                }
            } else {
                $findings.Add("AUDIT EXCEPTION team: $liveName")
                if ($Prune -and $Mode -eq 'WhatIf') { & $rWould "delete orphan team: $liveName" }
                else                                { & $rOrphan "team: $liveName" }
            }
        }
    }

    # ── 3. Security groups ────────────────────────────────────────────────────
    Write-Host "`n--- Security groups ---" -ForegroundColor Cyan

    $liveGroups = Get-AdoGroupSet -OrgUrl $OrgUrl -Project $project
    $seen       = [System.Collections.Generic.HashSet[string]]::new()

    foreach ($team in ($Resolved.teams | Where-Object { $_.scope -ne 'future' })) {
        foreach ($grp in @($team.securityGroups | Where-Object { $_ })) {
            if (-not $seen.Add($grp.ado)) { continue }

            $groupExists = $liveGroups.ContainsKey($grp.ado)
            if (-not $groupExists) {
                if ($doFix) {
                    try {
                        $liveGroups[$grp.ado] = New-AdoGroup -OrgUrl $OrgUrl -Project $project -Name $grp.ado
                        & $rCreated "group: $($grp.ado)"
                        $groupExists = $true
                    } catch {
                        $findings.Add("ERROR creating group '$($grp.ado)': $_")
                        & $rError "create group '$($grp.ado)': $_"
                        continue
                    }
                } else {
                    $findings.Add("MISSING group: $($grp.ado)")
                    if ($Mode -eq 'WhatIf') { & $rWould "create group: $($grp.ado)"; continue }
                    else                     { & $rMissing "group: $($grp.ado)"; continue }
                }
            } else {
                & $rOk "group: $($grp.ado)"
            }

            if (-not $groupExists) { continue }

            # Entries are objects ({upn}/{group} + reason/expires, normalized at
            # build time); legacy plain UPN strings still accepted. An EMPTY list
            # is enforced — the group must have no members, extras are drift.
            $userEntries  = [System.Collections.Generic.List[object]]::new()
            $groupEntries = [System.Collections.Generic.List[object]]::new()
            foreach ($e in @($grp.members | Where-Object { $_ })) {
                if ($e -is [string]) {
                    if ($e -match '@') { $userEntries.Add(@{ upn = $e }) }
                    continue
                }
                if ($e.upn)   { $userEntries.Add($e);  continue }
                if ($e.group) { $groupEntries.Add($e); continue }
            }

            $descriptor = $liveGroups[$grp.ado]
            if (-not $descriptor) { continue }

            try {
                $liveMembers  = Get-AdoGroupMemberSet -OrgUrl $OrgUrl -Descriptor $descriptor
                $desiredDescs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
                $unresolved   = $false

                foreach ($m in $userEntries) {
                    $upn   = [string]$m.upn
                    $mDesc = Find-AdoUserDescriptor -OrgUrl $OrgUrl -Upn $upn
                    if (-not $mDesc) {
                        # Say WHY: either the org knows a near-match (config has
                        # the wrong UPN) or the user is not in the org at all.
                        $near = @(Find-AdoUserSuggestion -OrgUrl $OrgUrl -Upn $upn)
                        $why  = if ($near.Count -gt 0) {
                            "no org member has this exact UPN - did you mean: $($near -join ', ')?"
                        } else {
                            "no org member matches this UPN - the user must be added to the org (Entra membership alone is not enough) before governance can grant them access"
                        }
                        $findings.Add("UNRESOLVABLE member '$upn' in '$($grp.ado)': $why")
                        & $rError "unresolvable user '$upn' in '$($grp.ado)': $why"
                        $unresolved = $true
                        continue
                    }
                    $desiredDescs.Add($mDesc) | Out-Null
                    if (-not $liveMembers.ContainsKey($mDesc)) {
                        if ($doFix) {
                            try {
                                Add-AdoGroupMember -OrgUrl $OrgUrl -MemberDescriptor $mDesc -ContainerDescriptor $descriptor
                                & $rCreated "member: $upn -> $($grp.ado)"
                            } catch {
                                $findings.Add("ERROR adding member '$upn' to '$($grp.ado)': $_")
                                & $rError "add member '$upn' to '$($grp.ado)': $_"
                            }
                        } else {
                            $findings.Add("MISSING member '$upn' in '$($grp.ado)'")
                            if ($Mode -eq 'WhatIf') { & $rWould "add member: $upn -> $($grp.ado)" }
                            else                     { & $rMissing "member: $upn in $($grp.ado)" }
                        }
                    }
                }

                # Nested groups declared in config (e.g. Entra groups).
                foreach ($g in $groupEntries) {
                    $gName = [string]$g.group
                    $gDesc = Find-AdoGroupDescriptor -OrgUrl $OrgUrl -Project $project -Name $gName -ProjectGroups $liveGroups
                    if (-not $gDesc) {
                        $findings.Add("UNRESOLVABLE nested group '$gName' in '$($grp.ado)'")
                        & $rError "unresolvable nested group '$gName' in '$($grp.ado)'"
                        $unresolved = $true
                        continue
                    }
                    $desiredDescs.Add($gDesc) | Out-Null
                    if (-not $liveMembers.ContainsKey($gDesc)) {
                        if ($doFix) {
                            try {
                                Add-AdoGroupMember -OrgUrl $OrgUrl -MemberDescriptor $gDesc -ContainerDescriptor $descriptor
                                & $rCreated "nested group: $gName -> $($grp.ado)"
                            } catch {
                                $findings.Add("ERROR adding nested group '$gName' to '$($grp.ado)': $_")
                                & $rError "add nested group '$gName' to '$($grp.ado)': $_"
                            }
                        } else {
                            $findings.Add("MISSING nested group '$gName' in '$($grp.ado)'")
                            if ($Mode -eq 'WhatIf') { & $rWould "add nested group: $gName -> $($grp.ado)" }
                            else                     { & $rMissing "nested group: $gName in $($grp.ado)" }
                        }
                    }
                }

                # Extra members: user descriptors (aad./msa.) in the live group but
                # not in config. Nested groups are never touched — access.yaml allows
                # Entra groups to be nested and they can't be matched to the UPN list.
                # Skipped entirely if any desired member failed to resolve, because
                # an unresolved desired member is indistinguishable from an extra.
                if (-not $unresolved) {
                    $extras = @($liveMembers.Keys | Where-Object {
                        $_ -match '^(aad|msa)\.' -and -not $desiredDescs.Contains($_) })
                    foreach ($xDesc in $extras) {
                        $xName = Resolve-AdoMemberDisplay -OrgUrl $OrgUrl -Descriptor $xDesc
                        if ($Prune -and $doFix) {
                            try {
                                Remove-AdoGroupMember -OrgUrl $OrgUrl -MemberDescriptor $xDesc -ContainerDescriptor $descriptor
                                & $rDeleted "member: $xName <- $($grp.ado)"
                            } catch {
                                $findings.Add("ERROR removing extra member '$xName' from '$($grp.ado)': $_")
                                & $rError "remove member '$xName' from '$($grp.ado)': $_"
                            }
                        } else {
                            $findings.Add("DRIFT group '$($grp.ado)': extra member '$xName' not in config")
                            if ($Prune -and $Mode -eq 'WhatIf') { & $rWould "remove member: $xName from $($grp.ado)" }
                            else                                { & $rDrift "group '$($grp.ado)': extra member '$xName'" }
                        }
                    }
                }
            } catch { & $rError "read members of '$($grp.ado)': $_" }
        }
    }

    # ── 6. Repos ───────────────────────────────────────────────────────────────
    Write-Host "`n--- Repos ---" -ForegroundColor Cyan

    $liveRepos = Get-AdoRepoSet -OrgUrl $OrgUrl -Project $project
    foreach ($repo in @($Resolved.repos | Where-Object { $_ })) {
        if ($liveRepos.ContainsKey($repo.name)) {
            & $rOk "repo: $($repo.name)"
        } else {
            if ($doFix) {
                try {
                    New-AdoRepo -OrgUrl $OrgUrl -Project $project -Name $repo.name
                    & $rCreated "repo: $($repo.name)"
                } catch {
                    $findings.Add("ERROR creating repo '$($repo.name)': $_")
                    & $rError "create repo '$($repo.name)': $_"
                }
            } else {
                $findings.Add("MISSING repo: $($repo.name)")
                if ($Mode -eq 'WhatIf') { & $rWould "create repo: $($repo.name)" }
                else                     { & $rMissing "repo: $($repo.name)" }
            }
        }
    }

    # Orphan repos: exist in ADO but not in the resolved model. The project
    # default repo (named after the project) is exempt. Pruning soft-deletes
    # to the recycle bin (~30 days recoverable).
    $desiredRepoNames = [System.Collections.Generic.HashSet[string]]::new(
        [string[]]@(@($Resolved.repos | Where-Object { $_ } | ForEach-Object { $_.name }) + @($project)),
        [System.StringComparer]::OrdinalIgnoreCase)
    foreach ($liveRepoName in ($liveRepos.Keys | Sort-Object)) {
        if (-not $desiredRepoNames.Contains($liveRepoName)) {
            if ($Prune -and $doFix) {
                try {
                    Remove-AdoRepo -OrgUrl $OrgUrl -Project $project -RepoId $liveRepos[$liveRepoName]
                    & $rDeleted "repo: $liveRepoName"
                } catch {
                    $findings.Add("ERROR deleting orphan repo '$liveRepoName': $_")
                    & $rError "delete repo '$liveRepoName': $_"
                }
            } else {
                $findings.Add("AUDIT EXCEPTION repo: $liveRepoName")
                if ($Prune -and $Mode -eq 'WhatIf') { & $rWould "delete orphan repo: $liveRepoName" }
                else                                { & $rOrphan "repo: $liveRepoName" }
            }
        }
    }

    # ── 6b. Repo ACLs ──────────────────────────────────────────────────────────
    # Everyone in the project reads; only the owning team's group writes;
    # innerOSS additionally opens branch creation + PR contribution to everyone.
    $projectId     = $null   # lazy-loaded once for ACL security token construction
    $identityCache = @{}     # subjectDescriptor -> securityIdentityDescriptor

    foreach ($repo in @($Resolved.repos | Where-Object { $_ -and $_.acl })) {
        if (-not $liveRepos.ContainsKey($repo.name)) {
            # Just-created repos need a fresh id lookup; refresh once.
            $liveRepos = Get-AdoRepoSet -OrgUrl $OrgUrl -Project $project
            if (-not $liveRepos.ContainsKey($repo.name)) { continue }   # creation failed — already reported
        }
        if (-not $projectId) {
            $projectId = Get-AdoProjectId -OrgUrl $OrgUrl -Project $project
            if (-not $projectId) {
                & $rError "could not resolve project ID — skipping repo ACL checks"
                break
            }
        }
        try {
            $liveAcl  = Get-AdoRepoAcl -OrgUrl $OrgUrl -ProjectId $projectId -RepoId $liveRepos[$repo.name]
            $aclDrift = [System.Collections.Generic.List[object]]::new()

            foreach ($ace in @($repo.acl | Where-Object { $_ })) {
                # 'project-valid-users' = the built-in everyone group.
                $groupName   = if ($ace.principal -eq 'project-valid-users') { 'Project Valid Users' } else { $ace.principal }
                $subjectDesc = $liveGroups[$groupName]
                if (-not $subjectDesc) {
                    $aclDrift.Add([ordered]@{
                        message      = "DRIFT repo '$($repo.name)': group '$groupName' not found in ADO"
                        identityDesc = $null; desiredBits = 0 })
                    continue
                }
                if (-not $identityCache.ContainsKey($subjectDesc)) {
                    $identityCache[$subjectDesc] = Get-AdoGroupIdentityDescriptor `
                        -OrgUrl $OrgUrl -SubjectDescriptor $subjectDesc
                }
                $identityDesc = $identityCache[$subjectDesc]
                if (-not $identityDesc) {
                    $aclDrift.Add([ordered]@{
                        message      = "DRIFT repo '$($repo.name)': cannot resolve security identity for '$groupName'"
                        identityDesc = $null; desiredBits = 0 })
                    continue
                }
                $desiredBits = ConvertTo-RepoPermissionBit -Permission $ace.permission
                $actualBits  = if ($liveAcl.ContainsKey($identityDesc)) { [int]$liveAcl[$identityDesc] } else { 0 }
                if (($actualBits -band $desiredBits) -ne $desiredBits) {
                    $aclDrift.Add([ordered]@{
                        message      = "DRIFT repo '$($repo.name)': '$groupName' ($($ace.permission)) has allow=$actualBits, need bits $desiredBits"
                        identityDesc = $identityDesc
                        desiredBits  = $desiredBits })
                }
            }

            if ($aclDrift.Count -eq 0) {
                & $rOk "repo ACL: $($repo.name)"
            } elseif ($doFix) {
                $fixFailed = $false
                foreach ($item in $aclDrift) {
                    if (-not $item.identityDesc) {
                        $findings.Add($item.message); & $rError $item.message; $fixFailed = $true; continue
                    }
                    try {
                        Set-AdoRepoAce -OrgUrl $OrgUrl -ProjectId $projectId -RepoId $liveRepos[$repo.name] `
                            -IdentityDescriptor $item.identityDesc -AllowBits $item.desiredBits
                    } catch {
                        $findings.Add("ERROR setting repo ACL on '$($repo.name)': $_")
                        & $rError "set repo ACL '$($repo.name)': $_"
                        $fixFailed = $true
                    }
                }
                if (-not $fixFailed) { & $rFixed "repo ACL: $($repo.name)" }
            } else {
                foreach ($item in $aclDrift) { $findings.Add($item.message) }
                if ($Mode -eq 'WhatIf') { & $rWould "correct repo ACL: $($repo.name)" }
                else                     { & $rDrift "repo ACL: $($repo.name)" }
            }
        } catch {
            & $rError "read repo ACL for '$($repo.name)': $_"
        }
    }

    # ── 7. Pipeline folders ────────────────────────────────────────────────────────
    Write-Host "`n--- Pipeline folders ---" -ForegroundColor Cyan

    $liveFolders   = Get-AdoPipelineFolderSet -OrgUrl $OrgUrl -Project $project

    foreach ($folder in @($Resolved.pipelineFolders | Where-Object { $_ })) {
        $folderExists = $liveFolders.ContainsKey($folder.path)

        if (-not $folderExists) {
            if ($doFix) {
                try {
                    New-AdoPipelineFolder -OrgUrl $OrgUrl -Project $project -Path $folder.path
                    & $rCreated "folder: $($folder.path)"
                    $folderExists = $true
                } catch {
                    $findings.Add("ERROR creating folder '$($folder.path)': $_")
                    & $rError "create folder '$($folder.path)': $_"
                }
            } else {
                $findings.Add("MISSING pipeline folder: $($folder.path)")
                if ($Mode -eq 'WhatIf') { & $rWould "create folder: $($folder.path)" }
                else                     { & $rMissing "folder: $($folder.path)" }
            }
        } else {
            & $rOk "folder: $($folder.path)"
        }

        # ── ACL check ─────────────────────────────────────────────────────────
        if (-not $folderExists) { continue }
        $desiredAcl = @($folder.acl | Where-Object { $_ })
        if ($desiredAcl.Count -eq 0) { continue }

        # Lazy-load the project ID needed to build the security token
        if (-not $projectId) {
            $projectId = Get-AdoProjectId -OrgUrl $OrgUrl -Project $project
            if (-not $projectId) {
                & $rError "could not resolve project ID — skipping ACL checks for pipeline folders"
                continue
            }
        }

        try {
            $liveAcl  = Get-AdoPipelineFolderAcl -OrgUrl $OrgUrl -ProjectId $projectId -FolderPath $folder.path
            $aclDrift = [System.Collections.Generic.List[object]]::new()

            foreach ($ace in $desiredAcl) {
                # Resolve the graph subject descriptor for this group name
                $subjectDesc = $liveGroups[$ace.group]
                if (-not $subjectDesc) {
                    $aclDrift.Add([ordered]@{
                        message      = "DRIFT pipeline folder '$($folder.path)': group '$($ace.group)' not found in ADO"
                        identityDesc = $null
                        desiredBits  = 0
                    })
                    continue
                }

                # Resolve (and cache) the security identity descriptor
                if (-not $identityCache.ContainsKey($subjectDesc)) {
                    $identityCache[$subjectDesc] = Get-AdoGroupIdentityDescriptor `
                        -OrgUrl $OrgUrl -SubjectDescriptor $subjectDesc
                }
                $identityDesc = $identityCache[$subjectDesc]
                if (-not $identityDesc) {
                    $aclDrift.Add([ordered]@{
                        message      = "DRIFT pipeline folder '$($folder.path)': cannot resolve security identity for '$($ace.group)'"
                        identityDesc = $null
                        desiredBits  = 0
                    })
                    continue
                }

                $desiredBits = ConvertTo-PipelinePermissionBit -Permission $ace.permission
                $actualBits  = if ($liveAcl.ContainsKey($identityDesc)) { [int]$liveAcl[$identityDesc] } else { 0 }
                if (($actualBits -band $desiredBits) -ne $desiredBits) {
                    $aclDrift.Add([ordered]@{
                        message      = "DRIFT pipeline folder '$($folder.path)': '$($ace.group)' has allow=$actualBits, need bits $desiredBits"
                        identityDesc = $identityDesc
                        desiredBits  = $desiredBits
                    })
                }
            }

            if ($aclDrift.Count -eq 0) {
                & $rOk "folder ACL: $($folder.path)"
            } elseif ($doFix) {
                $fixFailed = $false
                foreach ($item in $aclDrift) {
                    if (-not $item.identityDesc) {
                        # Unresolvable group — always a finding
                        $findings.Add($item.message)
                        & $rError $item.message
                        $fixFailed = $true
                        continue
                    }
                    try {
                        Set-AdoPipelineFolderAce -OrgUrl $OrgUrl -ProjectId $projectId `
                            -FolderPath $folder.path -IdentityDescriptor $item.identityDesc `
                            -AllowBits $item.desiredBits
                    } catch {
                        $findings.Add("ERROR setting ACL on '$($folder.path)' for identity '$($item.identityDesc)': $_")
                        & $rError "set ACL '$($folder.path)': $_"
                        $fixFailed = $true
                    }
                }
                if (-not $fixFailed) { & $rFixed "folder ACL: $($folder.path)" }
            } else {
                foreach ($item in $aclDrift) { $findings.Add($item.message) }
                if ($Mode -eq 'WhatIf') { & $rWould "correct folder ACL: $($folder.path)" }
                else                     { & $rDrift "folder ACL: $($folder.path)" }
            }
        } catch {
            & $rError "read ACL for '$($folder.path)': $_"
        }
    }

    # ── 4. Team iteration scope — runs after both teams and iteration paths exist ──
    # Exact-match: each team subscribes to precisely its in-scope window.
    # ROLLING MAINTENANCE, not compliance: the window is time-based and slides
    # daily, so keeping it current must not require an apply. Audit performs
    # the roll too (the scheduled audit keeps every team's window fresh) and
    # none of it is a finding — only real errors are. WhatIf stays dry.
    # The iteration PATHS themselves are never touched (ADR-005), only the
    # team's view of them.
    if ($Resolved.iterations -and $Resolved.iterations.config) {
        $doRoll = ($Mode -in @('Apply', 'Audit'))
        Write-Host "`n--- Team iteration scope ---" -ForegroundColor Cyan

        $itCfg   = $Resolved.iterations.config
        $progRoot = $Resolved.iterations.programRoot
        $calendar = Get-IterationCalendar -Cadence @{ iterations = $itCfg } -ProgramRoot $progRoot

        $tBack    = if ($itCfg.teamDefaults.sprints.back)         { [int]$itCfg.teamDefaults.sprints.back }         else { 10 }
        $tFwd     = if ($itCfg.teamDefaults.sprints.forward)      { [int]$itCfg.teamDefaults.sprints.forward }      else { 10 }
        $pBack    = if ($itCfg.portfolioDefaults.seasons.back)    { [int]$itCfg.portfolioDefaults.seasons.back }    else { 3 }
        $pFwd     = if ($itCfg.portfolioDefaults.seasons.forward) { [int]$itCfg.portfolioDefaults.seasons.forward } else { 3 }

        $iterIdCache = @{}
        # The root iteration subscription is set by Initialize-AdoTeamDefaults so
        # team settings PATCH calls succeed — it is never treated as an extra.
        $iterRootId  = $null
        try { $iterRootId = Get-AdoIterationRootId -OrgUrl $OrgUrl -Project $project }
        catch { & $rError "root iteration node: $_" }

        foreach ($team in ($Resolved.teams | Where-Object { $_.scope -ne 'future' -and $_.name -ne $project })) {
            # Team missing entirely — already flagged in the Teams section.
            if (-not $liveTeamNames.Contains($team.name)) { continue }

            # The team's backlog iteration must be the PROJECT ROOT — rolling
            # windows span execution years, and ADO hides every subscription
            # outside the backlog-iteration subtree. Part of the rolling
            # maintenance: corrected in apply AND audit, never a finding.
            if ($iterRootId) {
                try {
                    $currentRoot = Get-AdoTeamBacklogIterationId -OrgUrl $OrgUrl -Project $project -Team $team.name
                    if ($currentRoot -and $currentRoot -ne $iterRootId) {
                        if ($doRoll) {
                            Set-AdoTeamBacklogIteration -OrgUrl $OrgUrl -Project $project -Team $team.name -IterationId $iterRootId
                            & $rFixed "team: $($team.name) backlog iteration -> project root"
                        } else {
                            & $rWould "set backlog iteration to project root for team: $($team.name)"
                        }
                    }
                } catch {
                    $findings.Add("ERROR checking backlog iteration for '$($team.name)': $_")
                    & $rError "backlog iteration '$($team.name)': $_"
                }
            }

            # iterationScope comes from the team's type (portfolio/structural ->
            # seasons, delivery -> sprints) or a per-node override; kind is the
            # fallback for resolved models built before team types existed.
            # 'none' means an empty desired window — everything but root is drift.
            $scope = if ($team.iterationScope) { [string]$team.iterationScope }
                     elseif ($team.kind -in @('portfolio', 'product')) { 'seasons' }
                     else { 'sprints' }
            $scopePaths = switch ($scope) {
                'none'    { @() }
                'seasons' { @(Get-InScopeSeasonPaths -Calendar $calendar -Back $pBack -Forward $pFwd) }
                default   { @(Get-InScopeSprintPaths -Calendar $calendar -Back $tBack -Forward $tFwd) }
            }

            $desiredIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($iterPath in $scopePaths) {
                $guid = if ($iterIdCache.ContainsKey($iterPath)) { $iterIdCache[$iterPath] }
                        else {
                            try { $id = Get-AdoIterationId -OrgUrl $OrgUrl -Project $project -ResolvedPath $iterPath
                                  $iterIdCache[$iterPath] = $id; $id
                            } catch { $null }   # path missing — already flagged in Iterations section
                        }
                if ($guid) { $desiredIds.Add($guid) | Out-Null }
            }

            try {
                $currentIds = Get-AdoTeamIterationSet -OrgUrl $OrgUrl -Project $project -Team $team.name
                $missingIds = @($desiredIds | Where-Object { -not $currentIds.ContainsKey($_) })
                $extraIds   = @($currentIds.Keys | Where-Object {
                    $_ -ne $iterRootId -and -not $desiredIds.Contains($_) })

                if ($missingIds.Count -eq 0 -and $extraIds.Count -eq 0) {
                    & $rOk "team: $($team.name) (iteration scope current: $scope)"
                } elseif ($doRoll) {
                    foreach ($guid in $missingIds) {
                        Add-AdoTeamIteration -OrgUrl $OrgUrl -Project $project -Team $team.name -IterationId $guid
                    }
                    foreach ($guid in $extraIds) {
                        Remove-AdoTeamIteration -OrgUrl $OrgUrl -Project $project -Team $team.name -IterationId $guid
                    }
                    & $rFixed "team: $($team.name) (rolled window +$($missingIds.Count)/-$($extraIds.Count) iteration subscription(s))"
                } else {
                    # WhatIf: dry run — report, never a finding (rolling maintenance).
                    & $rWould "roll iteration window for team: $($team.name) (+$($missingIds.Count)/-$($extraIds.Count))"
                }
            } catch {
                $findings.Add("ERROR reconciling iteration scope for '$($team.name)': $_")
                & $rError "iteration scope for '$($team.name)': $_"
            }
        }
    }

    # ── 5. Team backlog levels ────────────────────────────────────────────────
    # Each team's visible backlog levels must match its type exactly (e.g.
    # delivery teams see Requirements + Stories, portfolio teams Initiatives).
    # Wrong visibility in either direction is drift.
    $backlogTeams = @($desiredTeams | Where-Object { $_.backlogs })
    if ($backlogTeams.Count -gt 0) {
        Write-Host "`n--- Team backlog levels ---" -ForegroundColor Cyan

        foreach ($team in $backlogTeams) {
            # Team missing entirely — already flagged in the Teams section.
            if (-not $liveTeamNames.Contains($team.name)) { continue }
            try {
                $levels = @(Get-AdoTeamBacklogLevels -OrgUrl $OrgUrl -Project $project -Team $team.name)
                $live   = Get-AdoTeamBacklogVisibilities -OrgUrl $OrgUrl -Project $project -Team $team.name

                $levelNames = @($levels | ForEach-Object { $_.name })
                foreach ($name in @($team.backlogs)) {
                    if ($name -notin $levelNames) {
                        $findings.Add("DRIFT team '$($team.name)': configured backlog level '$name' does not exist in the process (levels: $($levelNames -join ', '))")
                        & $rError "team '$($team.name)': backlog level '$name' not in process"
                    }
                }

                # If NO configured level exists in this process, reconciling
                # visibility would hide every level — wrong, and rejected by
                # ADO (VS402489). The findings above are the real problem: the
                # config names must match the process before visibility can
                # be enforced.
                if (@($team.backlogs | Where-Object { $_ -in $levelNames }).Count -eq 0) { continue }

                $desiredVis = @{}
                $driftItems = [System.Collections.Generic.List[string]]::new()
                foreach ($lvl in $levels) {
                    $want = $lvl.name -in @($team.backlogs)
                    $desiredVis[$lvl.id] = $want
                    $have = if ($live.ContainsKey($lvl.id)) { [bool]$live[$lvl.id] } else { $null }
                    if ($have -ne $want) {
                        $haveDesc = if ($null -eq $have) { 'unknown' } elseif ($have) { 'visible' } else { 'hidden' }
                        $wantDesc = if ($want) { 'visible' } else { 'hidden' }
                        $driftItems.Add("DRIFT team '$($team.name)': backlog '$($lvl.name)' should be $wantDesc but is $haveDesc")
                    }
                }

                if ($driftItems.Count -eq 0) {
                    & $rOk "team: $($team.name) (backlog levels)"
                } elseif ($doFix) {
                    try {
                        Set-AdoTeamBacklogVisibilities -OrgUrl $OrgUrl -Project $project `
                            -Team $team.name -Visibilities $desiredVis
                        & $rFixed "team: $($team.name) backlog levels"
                    } catch {
                        # ADO REST errors carry a JSON body; surface just the
                        # message, not the whole serialized exception object.
                        $msg = "$_"
                        try { $msg = ("$_" | ConvertFrom-Json).message } catch {}
                        foreach ($d in $driftItems) { $findings.Add($d) }
                        $findings.Add("ERROR setting backlog levels for '$($team.name)': $msg")
                        & $rError "set backlog levels '$($team.name)': $msg"
                    }
                } else {
                    foreach ($d in $driftItems) { $findings.Add($d) }
                    if ($Mode -eq 'WhatIf') { & $rWould "correct backlog levels for team: $($team.name)" }
                    else                     { & $rDrift "team: $($team.name) backlog levels wrong" }
                }
            } catch {
                $findings.Add("ERROR reading backlog levels for '$($team.name)': $_")
                & $rError "read backlog levels for '$($team.name)': $_"
            }
        }
    }

    # ── 5c. Team administrators ───────────────────────────────────────────────
    # members/<code>.yaml `teamAdmins:` governs the ADO Team Administrator role
    # exactly: empty (or absent) list = the team must have NO administrators.
    # Missing and extra admins are both findings; apply corrects both.
    Write-Host "`n--- Team administrators ---" -ForegroundColor Cyan
    foreach ($team in @($Resolved.teams | Where-Object { $_.scope -ne 'future' })) {
        if (-not $liveTeamNames.Contains($team.name)) { continue }
        $teamId = $liveTeams[$team.name]
        if (-not $teamId) { continue }
        if (-not $projectId) {
            $projectId = Get-AdoProjectId -OrgUrl $OrgUrl -Project $project
            if (-not $projectId) { & $rError "could not resolve project ID — skipping team administrator checks"; break }
        }
        try {
            # Desired: resolve each entry (upn or governance group) to a
            # security identity descriptor.
            $desiredAdmins = @{}   # identityDescriptor -> display name
            $unresolvedAdmin = $false
            foreach ($m in @($team.teamAdmins | Where-Object { $_ })) {
                $subjectDesc = $null
                $display     = $null
                if ($m.upn) {
                    $display     = [string]$m.upn
                    $subjectDesc = Find-AdoUserDescriptor -OrgUrl $OrgUrl -Upn $display
                    if (-not $subjectDesc) {
                        $near = @(Find-AdoUserSuggestion -OrgUrl $OrgUrl -Upn $display)
                        $why  = if ($near.Count -gt 0) { "no org member has this exact UPN - did you mean: $($near -join ', ')?" }
                                else { "no org member matches this UPN" }
                        $findings.Add("UNRESOLVABLE team admin '$display' for '$($team.name)': $why")
                        & $rError "unresolvable team admin '$display' for '$($team.name)': $why"
                        $unresolvedAdmin = $true
                        continue
                    }
                } elseif ($m.group) {
                    $display     = [string]$m.group
                    $subjectDesc = $liveGroups[$display]
                    if (-not $subjectDesc) {
                        $findings.Add("UNRESOLVABLE team admin group '$display' for '$($team.name)': no such group in ADO")
                        & $rError "unresolvable team admin group '$display' for '$($team.name)'"
                        $unresolvedAdmin = $true
                        continue
                    }
                } else { continue }
                if (-not $identityCache.ContainsKey($subjectDesc)) {
                    $identityCache[$subjectDesc] = Get-AdoGroupIdentityDescriptor -OrgUrl $OrgUrl -SubjectDescriptor $subjectDesc
                }
                if ($identityCache[$subjectDesc]) { $desiredAdmins[$identityCache[$subjectDesc]] = $display }
                else {
                    $findings.Add("UNRESOLVABLE team admin '$display' for '$($team.name)': cannot resolve security identity")
                    & $rError "unresolvable team admin '$display' for '$($team.name)': cannot resolve security identity"
                    $unresolvedAdmin = $true
                }
            }

            $liveAdmins = Get-AdoTeamAdminSet -OrgUrl $OrgUrl -ProjectId $projectId -TeamId $teamId
            $missing = @($desiredAdmins.Keys | Where-Object { -not $liveAdmins.ContainsKey($_) })
            # Never remove admins while any desired entry failed to resolve —
            # a typo must not strip a team of every administrator.
            $extra   = if ($unresolvedAdmin) { @() } else { @($liveAdmins.Keys | Where-Object { -not $desiredAdmins.ContainsKey($_) }) }

            if ($missing.Count -eq 0 -and $extra.Count -eq 0) {
                & $rOk "team admins: $($team.name) ($($desiredAdmins.Count) admin(s))"
            } elseif ($doFix) {
                foreach ($d in $missing) {
                    try {
                        Add-AdoTeamAdmin -OrgUrl $OrgUrl -ProjectId $projectId -TeamId $teamId -IdentityDescriptor $d
                        & $rCreated "team admin: $($desiredAdmins[$d]) -> $($team.name)"
                    } catch {
                        $findings.Add("ERROR adding team admin '$($desiredAdmins[$d])' to '$($team.name)': $_")
                        & $rError "add team admin '$($desiredAdmins[$d])' to '$($team.name)': $_"
                    }
                }
                foreach ($d in $extra) {
                    $xName = Resolve-AdoMemberDisplay -OrgUrl $OrgUrl -Descriptor $d
                    try {
                        Remove-AdoTeamAdmin -OrgUrl $OrgUrl -ProjectId $projectId -TeamId $teamId -IdentityDescriptor $d
                        & $rDeleted "team admin: $xName <- $($team.name)"
                    } catch {
                        $findings.Add("ERROR removing extra team admin '$xName' from '$($team.name)': $_")
                        & $rError "remove team admin '$xName' from '$($team.name)': $_"
                    }
                }
            } else {
                foreach ($d in $missing) { $findings.Add("MISSING team admin '$($desiredAdmins[$d])' on '$($team.name)'") }
                foreach ($d in $extra)   { $findings.Add("DRIFT team '$($team.name)': extra team admin not in config") }
                if ($Mode -eq 'WhatIf') { & $rWould "correct team admins for: $($team.name) (+$($missing.Count)/-$($extra.Count))" }
                else                     { & $rDrift "team admins: $($team.name) (+$($missing.Count)/-$($extra.Count))" }
            }
        } catch {
            $findings.Add("ERROR reconciling team admins for '$($team.name)': $_")
            & $rError "team admins for '$($team.name)': $_"
        }
    }

    # ── 6. Structural authority (area node ACLs) ──────────────────────────────
    # Each {key}-Admins group must hold node-admin bits (read/write/delete) on
    # every area path its team governs (Decision-0028 delegated ownership).
    $authorityEntries = @($Resolved.structuralAuthority | Where-Object { $_ })
    if ($authorityEntries.Count -gt 0) {
        Write-Host "`n--- Structural authority ---" -ForegroundColor Cyan

        $authIdentity = @{}   # subjectDescriptor -> securityIdentityDescriptor
        $authTokens   = @{}   # area path -> CSS token
        foreach ($entry in $authorityEntries) {
            $subjectDesc = $liveGroups[$entry.group]
            if (-not $subjectDesc) { continue }   # group missing — flagged in the groups section

            if (-not $authIdentity.ContainsKey($subjectDesc)) {
                $authIdentity[$subjectDesc] = Get-AdoGroupIdentityDescriptor -OrgUrl $OrgUrl -SubjectDescriptor $subjectDesc
            }
            $identityDesc = $authIdentity[$subjectDesc]
            if (-not $identityDesc) {
                $findings.Add("DRIFT structural authority: cannot resolve security identity for '$($entry.group)'")
                & $rError "structural authority: identity for '$($entry.group)'"
                continue
            }

            foreach ($path in @($entry.paths)) {
                if (-not $liveAreas.ContainsKey($path)) { continue }   # area missing — already flagged
                try {
                    if (-not $authTokens.ContainsKey($path)) {
                        $authTokens[$path] = Get-AdoAreaNodeToken -OrgUrl $OrgUrl -Project $project -ResolvedPath $path
                    }
                    $token  = $authTokens[$path]
                    $acl    = Get-AdoAreaNodeAcl -OrgUrl $OrgUrl -Token $token
                    $actual = if ($acl.ContainsKey($identityDesc)) { [int]$acl[$identityDesc] } else { 0 }
                    if (($actual -band $script:AreaNodeAdminBits) -eq $script:AreaNodeAdminBits) {
                        & $rOk "authority: $($entry.group) on $path"
                    } elseif ($doFix) {
                        try {
                            Set-AdoAreaNodeAce -OrgUrl $OrgUrl -Token $token `
                                -IdentityDescriptor $identityDesc -AllowBits $script:AreaNodeAdminBits
                            & $rFixed "authority: $($entry.group) on $path"
                        } catch {
                            $findings.Add("ERROR granting structural authority to '$($entry.group)' on '$path': $_")
                            & $rError "grant authority '$($entry.group)' on '$path': $_"
                        }
                    } else {
                        $findings.Add("DRIFT structural authority: '$($entry.group)' lacks node-admin rights on '$path' (allow=$actual, need $script:AreaNodeAdminBits)")
                        if ($Mode -eq 'WhatIf') { & $rWould "grant node-admin to $($entry.group) on $path" }
                        else                     { & $rDrift "authority: $($entry.group) on $path" }
                    }
                } catch {
                    $findings.Add("ERROR reading node ACL for '$path': $_")
                    & $rError "node ACL for '$path': $_"
                }
            }
        }
    }

    # ── 7. Tags — governed taxonomy (Decision-0041, ADR-006) ──────────────────
    # Disallowed patterns (build-id shaped) are ALWAYS drift; tags outside the
    # sanctioned vocabulary are audit exceptions; sanctioned tags that do not
    # exist are MISSING. ADO has no create-tag API and purges tags no work item
    # references, so apply makes a sanctioned tag exist the only way ADO allows:
    # by holding the whole vocabulary on one governed anchor work item.
    if ($Resolved.tags) {
        Write-Host "`n--- Tags ---" -ForegroundColor Cyan
        try {
            $liveTags   = Get-AdoTagSet -OrgUrl $OrgUrl -Project $project
            $sanctionedList = @($Resolved.tags.sanctioned)
            $sanctioned = [System.Collections.Generic.HashSet[string]]::new(
                [string[]]$sanctionedList, [System.StringComparer]::OrdinalIgnoreCase)
            $patterns = @($Resolved.tags.disallowedPatterns)
            $okCount  = 0

            # ── 7a. Anchor: make the sanctioned vocabulary exist ──────────────
            # A resolved.yaml built before ADR-006 has no anchor block; treat that
            # as disabled rather than writing a work item off a stale model.
            $anchor  = $Resolved.tags.anchor
            $seeding = ($anchor -and $anchor.enabled -and $sanctionedList.Count -gt 0)
            $missing = @($sanctionedList | Where-Object { -not $liveTags.ContainsKey($_) })

            if ($seeding -and ($missing.Count -gt 0 -or $doFix)) {
                if ($doFix) {
                    try {
                        $wiTypes = Get-AdoWorkItemTypeName -OrgUrl $OrgUrl -Project $project
                        if ($anchor.workItemType -notin $wiTypes) {
                            throw ("work item type '$($anchor.workItemType)' does not exist in '$project'. " +
                                   "Set taxonomy.yaml tags.anchor.workItemType to one of: $($wiTypes -join ', ')")
                        }
                        $anchorId = Find-AdoTagAnchor -OrgUrl $OrgUrl -Project $project -Title $anchor.title
                        if (-not $anchorId) {
                            $anchorId = New-AdoTagAnchor -OrgUrl $OrgUrl -Project $project `
                                -Title $anchor.title -WorkItemType $anchor.workItemType `
                                -Tags $sanctionedList -AreaPath $anchor.areaPath -State $anchor.state
                            & $rCreated "tag anchor: work item #$anchorId"
                            foreach ($t in ($missing | Sort-Object)) { & $rCreated "tag: $t [seeded]" }
                        } else {
                            # Corrective (ADR-003): the anchor must carry exactly the
                            # sanctioned set — no more (extras would read as orphan
                            # tags), no less (gaps would read as missing tags).
                            # HashSet, not Compare-Object: Compare-Object rejects an
                            # empty -ReferenceObject, and an anchor stripped of its
                            # tags is exactly the case that most needs correcting.
                            $onAnchor = [System.Collections.Generic.HashSet[string]]::new(
                                [string[]]@(Get-AdoWorkItemTag -OrgUrl $OrgUrl -Project $project -Id $anchorId),
                                [System.StringComparer]::OrdinalIgnoreCase)
                            if (-not $onAnchor.SetEquals([string[]]$sanctionedList)) {
                                Set-AdoTagAnchorTag -OrgUrl $OrgUrl -Project $project -Id $anchorId -Tags $sanctionedList
                                & $rFixed "tag anchor: work item #$anchorId"
                                foreach ($t in ($missing | Sort-Object)) { & $rCreated "tag: $t [seeded]" }
                            } else {
                                & $rOk "tag anchor: work item #$anchorId"
                            }
                        }
                        # Re-read: seeding changed the live vocabulary.
                        $liveTags = Get-AdoTagSet -OrgUrl $OrgUrl -Project $project
                        $missing  = @($sanctionedList | Where-Object { -not $liveTags.ContainsKey($_) })
                        foreach ($t in ($missing | Sort-Object)) {
                            $findings.Add("MISSING tag: $t (still absent after seeding the anchor work item)")
                            & $rMissing "tag: $t"
                        }
                    } catch {
                        $findings.Add("ERROR seeding tag anchor: $_")
                        & $rError "seed tag anchor: $_"
                    }
                } else {
                    foreach ($t in ($missing | Sort-Object)) {
                        $findings.Add("MISSING tag: $t (sanctioned but not in use)")
                        if ($Mode -eq 'WhatIf') { & $rWould "seed tag: $t (via anchor work item)" }
                        else                     { & $rMissing "tag: $t (sanctioned but not in use)" }
                    }
                }
            } elseif ($missing.Count -gt 0) {
                # Seeding disabled: report, and say plainly that apply cannot fix it.
                $why = if ($anchor) { 'tags.anchor.enabled is false' } else { 'resolved.yaml predates the tag anchor — rebuild' }
                foreach ($t in ($missing | Sort-Object)) {
                    $findings.Add("MISSING tag: $t (sanctioned but not in use; apply cannot create it — $why)")
                    & $rMissing "tag: $t (sanctioned but not in use)"
                }
            }

            foreach ($tagName in ($liveTags.Keys | Sort-Object)) {
                $disallowed = $false
                foreach ($p in $patterns) { if ($tagName -match $p) { $disallowed = $true; break } }

                if ($disallowed) {
                    if ($Prune -and $doFix) {
                        try {
                            Remove-AdoTag -OrgUrl $OrgUrl -Project $project -TagId $liveTags[$tagName]
                            & $rDeleted "tag: $tagName"
                        } catch {
                            $findings.Add("ERROR deleting disallowed tag '$tagName': $_")
                            & $rError "delete tag '$tagName': $_"
                        }
                    } else {
                        $findings.Add("DRIFT tag '$tagName': matches a disallowed pattern")
                        if ($Prune -and $Mode -eq 'WhatIf') { & $rWould "delete disallowed tag: $tagName" }
                        else                                { & $rDrift "tag: $tagName (disallowed pattern)" }
                    }
                } elseif (-not $sanctioned.Contains($tagName)) {
                    if ($Prune -and $doFix) {
                        try {
                            Remove-AdoTag -OrgUrl $OrgUrl -Project $project -TagId $liveTags[$tagName]
                            & $rDeleted "tag: $tagName"
                        } catch {
                            $findings.Add("ERROR deleting unsanctioned tag '$tagName': $_")
                            & $rError "delete tag '$tagName': $_"
                        }
                    } else {
                        $findings.Add("AUDIT EXCEPTION tag: $tagName")
                        if ($Prune -and $Mode -eq 'WhatIf') { & $rWould "delete unsanctioned tag: $tagName" }
                        else                                { & $rOrphan "tag: $tagName" }
                    }
                } else { $okCount++ }
            }
            Write-Host " [ok] $okCount sanctioned tag(s) in use ($($liveTags.Count) live total)" -ForegroundColor Green
        } catch {
            # A read failure means the taxonomy was never checked — that must be a
            # finding, not just a console line, or the run reports a false green.
            $findings.Add("ERROR reading tags: $_")
            & $rError "read tags: $_"
        }
    }

    # ── Findings summary ──────────────────────────────────────────────────────
    $exceptions = @($findings | Where-Object { $_ -like 'AUDIT EXCEPTION*' })
    $missing    = @($findings | Where-Object { $_ -like 'MISSING*' })
    $errors     = @($findings | Where-Object { $_ -like 'ERROR*' -or $_ -like 'UNRESOLVABLE*' })
    $drift      = @($findings | Where-Object {
        $_ -notlike 'AUDIT EXCEPTION*' -and $_ -notlike 'MISSING*' -and
        $_ -notlike 'ERROR*' -and $_ -notlike 'UNRESOLVABLE*' })

    # Group each error under its diagnosed root cause so the summary reads as
    # "here is what failed and WHY", not a wall of identical stack noise.
    $errorsByWhy = [ordered]@{}
    foreach ($e in $errors) {
        $why = Resolve-GovernanceErrorReason -Finding $e
        if (-not $why) { $why = 'cause not yet diagnosed - investigate, then teach Resolve-GovernanceErrorReason the signature' }
        if (-not $errorsByWhy.Contains($why)) { $errorsByWhy[$why] = [System.Collections.Generic.List[string]]::new() }
        $errorsByWhy[$why].Add($e)
    }

    Write-Host ''
    if ($findings.Count -eq 0) {
        Write-Host "COMPLIANT — zero findings." -ForegroundColor Green
    } else {
        $suffix = switch ($Mode) {
            'Apply'  { 'remaining after apply' }
            'WhatIf' { 'found (dry-run — no changes made)' }
            default  { 'found' }
        }
        Write-Host "NON-COMPLIANT — $($findings.Count) finding(s) $suffix." -ForegroundColor Red
        if ($missing.Count -gt 0) {
            Write-Host "`n Missing ($($missing.Count)):" -ForegroundColor Red
            $missing | ForEach-Object { Write-Host " * $_" -ForegroundColor Red }
        }
        if ($drift.Count -gt 0) {
            Write-Host "`n Drift ($($drift.Count)):" -ForegroundColor Red
            $drift | ForEach-Object { Write-Host " * $_" -ForegroundColor Red }
        }
        if ($errors.Count -gt 0) {
            Write-Host "`n Errors and why ($($errors.Count)):" -ForegroundColor Red
            foreach ($why in $errorsByWhy.Keys) {
                Write-Host " WHY: $why" -ForegroundColor Yellow
                foreach ($e in $errorsByWhy[$why]) { Write-Host " * $e" -ForegroundColor Red }
            }
        }
        if ($exceptions.Count -gt 0) {
            Write-Host "`n Audit failures — exist in ADO but not in config ($($exceptions.Count)):" -ForegroundColor Magenta
            $exceptions | ForEach-Object { Write-Host " * $_" -ForegroundColor Magenta }
        }
    }

    # ── Write report file ─────────────────────────────────────────────────────
    if ($ReportPath) {
        $reportDir = Split-Path $ReportPath -Parent
        if ($reportDir -and -not (Test-Path $reportDir)) {
            New-Item -ItemType Directory -Path $reportDir -Force | Out-Null
        }
        $lines = [System.Collections.Generic.List[string]]::new()
        $lines.Add("Governance audit report")
        $lines.Add("Program : $project")
        $lines.Add("Org : $OrgUrl")
        $lines.Add("Mode : $Mode")
        $lines.Add("Generated: $(Get-Date -Format 'o')")
        $lines.Add("Findings : $($findings.Count)")
        $lines.Add('')
        $errorReportItems = @(foreach ($why in $errorsByWhy.Keys) {
            "WHY: $why"
            foreach ($e in $errorsByWhy[$why]) { " - $e" }
        })
        foreach ($section in @(
            @{ label = 'MISSING'; items = $missing },
            @{ label = 'DRIFT';   items = $drift },
            @{ label = 'ERRORS AND WHY'; items = $errorReportItems },
            @{ label = 'AUDIT FAILURES (exist in ADO but not in config)'; items = $exceptions }
        )) {
            if ($section.items.Count -eq 0) { continue }
            $lines.Add("$($section.label) ($($section.items.Count)):")
            $section.items | ForEach-Object { $lines.Add(" - $_") }
            $lines.Add('')
        }
        if ($findings.Count -eq 0) { $lines.Add('COMPLIANT') } else { $lines.Add('NON-COMPLIANT') }
        Set-Content -Path $ReportPath -Value $lines -Encoding utf8
        Write-Host "`nReport written to: $ReportPath" -ForegroundColor Cyan

        # Machine-readable twin of the text report — consumed by scheduled runs
        # and, eventually, the compliance dashboard. Findings classified by prefix.
        $jsonPath   = [System.IO.Path]::ChangeExtension($ReportPath, 'json')
        $classified = @($findings | ForEach-Object {
            $class = if ($_ -like 'MISSING*')              { 'missing' }
                     elseif ($_ -like 'AUDIT EXCEPTION*')  { 'exception' }
                     elseif ($_ -like 'UNRESOLVABLE*')     { 'unresolvable' }
                     elseif ($_ -like 'ERROR*')            { 'error' }
                     else                                  { 'drift' }
            $entry = [ordered]@{ class = $class; message = $_ }
            if ($class -in 'error', 'unresolvable') {
                $entry['why'] = Resolve-GovernanceErrorReason -Finding $_
            }
            $entry
        })
        [ordered]@{
            program      = $Resolved.program
            project      = $project
            org          = $OrgUrl
            mode         = $Mode
            generated    = (Get-Date).ToUniversalTime().ToString('o')
            compliant    = ($findings.Count -eq 0)
            findingCount = $findings.Count
            findings     = $classified
        } | ConvertTo-Json -Depth 5 | Set-Content -Path $jsonPath -Encoding utf8
        Write-Host "JSON report written to: $jsonPath" -ForegroundColor Cyan
    }

    if ($findings.Count -gt 0 -and $Mode -eq 'Audit') {
        Write-Error "Governance compliance: $($findings.Count) finding(s) $suffix."
    }

    return $findings.ToArray()
}

function Resolve-GovernanceErrorReason {
    <#
        .SYNOPSIS
        Maps an ERROR/UNRESOLVABLE finding to its known root cause, so the
        summary can print "here is WHY" instead of a bare failure. Returns
        $null for signatures not yet diagnosed — when you diagnose a new one,
        add its pattern here.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Finding)

    if ($Finding -match '401|Unauthorized|requires user authentication') {
        if ($Finding -match 'ACL|structural authority') {
            return "no usable credential can WRITE security ACLs. vso.security_manage is not selectable in the PAT UI; 'az login' enables the Entra route - but if the org rejects Entra tokens (forced sign-out), Conditional Access is blocking this device: use an org-managed device or VPN. Verify with 'doctor'."
        }
        return "the PAT is missing a scope for this resource family - run 'doctor' to identify which."
    }
    if ($Finding -match 'did you mean:') {
        return "the configured UPN does not exist in the org, but a near-match does - fix the UPN in members/<code>.yaml."
    }
    if ($Finding -like 'UNRESOLVABLE member*') {
        return "no org member matches this UPN - the user must be added to the Azure DevOps org before governance can grant access."
    }
    if ($Finding -like 'UNRESOLVABLE nested group*') {
        return "no ADO group with this name exists in the project - an Entra group must be surfaced in ADO (added to any group once) before it can be nested by governance."
    }
    if ($Finding -match 'does not exist in the process' -or $Finding -match 'not in process') {
        return "cadence.yaml teamTypes backlogs must use THIS process's backlog level names - the finding lists the valid names."
    }
    if ($Finding -match 'VS402489') {
        return "none of the configured backlog levels exist in the process, so reconcile would hide every level and ADO refuses - fix the names in cadence.yaml."
    }
    if ($Finding -match 'controller for path|does not implement IController') {
        return "the REST call went to the wrong service host - this is an engine bug in host routing (Resolve-AdoRequestUri); report it."
    }
    return $null
}