Public/Core/Invoke-IDBridge.ps1

<#
.SYNOPSIS
Top-level orchestrator: provision and synchronize AD and Google Workspace accounts from source data.

.DESCRIPTION
Entry point for an IDBridge run. Calls Initialize-IDBridge, applies the runtime switch
overrides, acquires the Google bearer token via Connect-IDBridgeGoogle (when
GoogleToken.Enabled), then runs the ordered pipeline: gather source data from the configured plugins, read
current AD/Google state, enrich and de-duplicate the source records, apply override rows, match
person IDs to existing accounts, and compute every change list (org units, deactivations,
updates/renames/moves, creates, and group membership) read-only. Before any writes it runs the
change-volume safety guard (ChangeThreshold). It then executes the AD and Google changes only
when the directory is enabled and Debug.readOnly is $false, and (in the finally block) sends
usage telemetry, runs the configured PostRun plugins with the RunResult object (user list CSV
exports live in the Invoke-PluginPostRunExport plugin), and pushes the run log to a Google
Sheet when configured. Per-user write errors are
logged and skipped; startup/OU-creation failures and a tripped change threshold abort the run.

.PARAMETER RootPath
Base directory for Config/Logs/Exports/Plugins/Data/Vault. Defaults to C:\IDBridge.

.PARAMETER ReadOnly
Override Debug.readOnly for this run. When set, every change list is computed but nothing is written.

.PARAMETER TestRun
Override Debug.testRun; plugins process a small subset for faster iteration.

.PARAMETER SkipADCheck
Override Debug.skipADCheck; do not throw if the ActiveDirectory module fails to import.

.PARAMETER TraceLogging
Override Debug.TraceLogging; emit Trace-level logging.

.PARAMETER SkipAD
Disable all AD processing (and AD group processing) for this run.

.PARAMETER SkipGoogle
Disable all Google processing (and Google group processing) for this run.

.PARAMETER SkipChangeThreshold
Bypass the change-volume safety guard (ChangeThreshold) for this run.

.PARAMETER DisableTelemetry
Disable usage telemetry for this run (overrides Telemetry.Tier). See PRIVACY.md.

.OUTPUTS
None. Side effects: AD/Google mutations (unless ReadOnly), PostRun plugin output, and log output.

.EXAMPLE
Invoke-IDBridge -ReadOnly -TraceLogging

.EXAMPLE
Invoke-IDBridge -RootPath 'C:\IDBridge'

.NOTES
   Created by: Sam Cattanach
   Modified: 2026-07-13
#>

function Invoke-IDBridge {
    [CmdletBinding()]
    param (
        [string]$RootPath = "C:\IDBridge",

        [switch]$ReadOnly,
        [switch]$TestRun,
        [switch]$SkipADCheck,
        [switch]$TraceLogging,
        [switch]$SkipAD,
        [switch]$SkipGoogle,
        [switch]$SkipChangeThreshold,
        [switch]$DisableTelemetry
    )

    $runStart = Get-Date

    try{
        #region Import Configuration
        try { Initialize-IDBridge -RootPath $RootPath } catch { Throw }

        try { $IDConfig = Get-IDBridgeConfig } catch { Throw }
        #endregion Import Configuration

        

        #region Apply Runtime Overrides
        if ($PSBoundParameters.ContainsKey('ReadOnly'))       { $IDConfig.Debug.readOnly       = [bool]$ReadOnly }
        if ($PSBoundParameters.ContainsKey('TestRun'))        { $IDConfig.Debug.testRun        = [bool]$TestRun }
        if ($PSBoundParameters.ContainsKey('SkipADCheck'))    { $IDConfig.Debug.skipADCheck    = [bool]$SkipADCheck }
        if ($PSBoundParameters.ContainsKey('TraceLogging'))   { $IDConfig.Debug.TraceLogging   = [bool]$TraceLogging }
        if ($SkipAD)      { $IDConfig.AD.enabled     = $false; $IDConfig.AD.enableGroupProcessing     = $false }
        if ($SkipGoogle)  { $IDConfig.Google.enabled = $false; $IDConfig.Google.enableGroupProcessing = $false }
        if ($SkipChangeThreshold -and $IDConfig.ContainsKey('ChangeThreshold')) { $IDConfig.ChangeThreshold.Enabled = $false }
        if ($DisableTelemetry) { $IDConfig.Telemetry = @{ Tier = 'Off' } }

        foreach ($key in $PSBoundParameters.Keys | Where-Object { $_ -ne 'RootPath' }) {
            Write-Log -Message "OVERRIDE: $key = $($PSBoundParameters[$key])" -Level Info
        }
        #endregion Apply Runtime Overrides




        #region Update Check
        # Notify-only: a newer gallery release logs a warning, nothing is ever auto-installed.
        # Fully self-contained - offline or gallery-blocked environments can never affect the run.
        try { Test-IDBridgeUpdateAvailable | Out-Null } catch { Write-Log -Message "Update check: Skipped ($($_.Exception.GetType().Name))." -Level Trace }
        #endregion Update Check




        #region Google Auth
        # Acquired here (not in Initialize-IDBridge) so setup sessions initialize cleanly
        # before the key secret exists. Gated on GoogleToken.Enabled only: -SkipGoogle runs
        # still need headers for Sheets plugins and Google Sheet logging.
        if ($IDConfig.GoogleToken.Enabled -eq $true) {
            try { Connect-IDBridgeGoogle } catch { Throw }
        } else {
            Write-Log -Message "Google API integration is disabled. Google-related functions will be skipped." -Level Trace
        }
        #endregion Google Auth




        Write-Log -Message "######## Phase: Gather Source & Directory Data ########"

        #region Plugins
        try {
            $plugins = Invoke-SourcePlugins
            
            $sourceData = $plugins.SourceData
            $overrideData = $plugins.OverrideData
        }
        catch { Throw }
        #endregion Plugins




        #region Get Google Data
        if ($IDConfig.Google.enabled -eq $true) {
            try {
                $googleData = Get-TargetDataGoogle -ErrorAction Stop
            }
            catch { Throw }
        }
        #endregion Get Google Data




        #region Get Data AD
        if ($IDConfig.AD.enabled -eq $true) {
            try {
                $adData = Get-TargetDataAD -ErrorAction Stop
            }
            catch { Throw }
        }
        #endregion Get Data AD




        #region Target Data Preparation
        if ($IDConfig.AD.enabled -eq $true) {
            $sourceData = Add-TargetDataAD -SourceData $sourceData -ADData $adData
        }

        if ($IDConfig.Google.enabled -eq $true) {
            $sourceData = Add-TargetDataGoogle -SourceData $sourceData -GoogleData $googleData
        }
        #endregion Target Data Preparation




        #region Remove Duplicate IDs
        $sourceData = Remove-IDBridgeDuplicateID -SourceData $sourceData
        #endregion Remove Duplicate IDs




        #region Process Override Data
        $sourceData = Merge-IDBridgeOverrideData -SourceData $sourceData -OverrideData $overrideData
        #endregion Process Override Data




        #region Groups Not Processed
        if ($IDConfig.Debug.TraceLogging -eq $true) {
            #AD Checks
            if ($IDConfig.AD.enabled -eq $true -and ($IDConfig.AD.enableGroupProcessing -eq $true -or $IDConfig.AD.enableGroupProcessingWhatIf -eq $true)) {
                Write-Log -Message "AD: Checking for Groups Proposed for Processing that do not exist in the Target Data." -Level Trace
                Show-GroupsNotProcessed -ProposedGroups $sourceData.GroupsProposed -TargetGroups $adData.Groups -Directory 'AD'
            }

            #Google Checks
            if ($IDConfig.Google.enabled -eq $true -and ($IDConfig.Google.enableGroupProcessing -eq $true -or $IDConfig.Google.enableGroupProcessingWhatIf -eq $true)) {
                Write-Log -Message "Google: Checking for Groups Proposed for Processing that do not exist in the Target Data." -Level Trace
                Show-GroupsNotProcessed -ProposedGroups $sourceData.GroupsProposed -TargetGroups $googleData.Groups.name -Directory 'Google'
            }
        }
        #endregion Groups Not Processed




        #region Match PersonIDs
        if ($IDConfig.AD.enabled -eq $true) {
            #Update filteredData list and ADLookupByID Table with AD User Info if No EmployeeID is Set and an existing user is found that matches
            $ADUsersToSetEmployeeID = Get-ADUsersToSetEmployeeID -UserList $sourceData -CurrentADUsers $adData.Users

            foreach ($item in $SourceData | Where-Object { $ADUsersToSetEmployeeID.ContainsKey($_.personID) }) {
                $matchAD = $ADUsersToSetEmployeeID[$item.personID]
                Write-Log -Message ("AD: Matched $($matchAD.User.UserPrincipalName) with EmployeeID: $($item.personID).")
                $item | Add-Member -MemberType NoteProperty -Name 'ADObject' -Value $matchAD.User -Force
                $item | Add-Member -MemberType NoteProperty -Name 'ADCurrentUserID' -Value $matchAD.ID -Force
                $item | Add-Member -MemberType NoteProperty -Name 'ADCurrentGroups' -Value $matchAD.Groups -Force
                $item | Add-Member -MemberType NoteProperty -Name 'ADCurrentUserEnabledStatus' -Value $matchAD.EnabledStatus -Force
                $ADData.LookupByID[$item.personID] = $matchAD.User
            }
        }

        if ($IDConfig.Google.enabled -eq $true) {
            #Update filteredData list and GoogleLookupByID Table with Google User Info if No EmployeeID is Set and an existing user is found that matches
            $GoogleUsersToSetEmployeeID = Get-GoogleUsersToSetEmployeeID -UserList $sourceData -GoogleUsers $googleData.Users

            foreach ($item in $SourceData | Where-Object { $GoogleUsersToSetEmployeeID.ContainsKey($_.personID) }) {
                $matchGoogle = $GoogleUsersToSetEmployeeID[$item.personID]
                Write-Log -Message ("Google: Matched $($matchGoogle.User.primaryEmail) with EmployeeID: $($item.personID).")
                $item | Add-Member -MemberType NoteProperty -Name 'GoogleObject' -Value $matchGoogle.User -Force
                $item | Add-Member -MemberType NoteProperty -Name 'GoogleCurrentUserID' -Value $matchGoogle.ID -Force
                $item | Add-Member -MemberType NoteProperty -Name 'GoogleCurrentGroups' -Value $matchGoogle.Groups -Force
                $item | Add-Member -MemberType NoteProperty -Name 'GoogleCurrentUserSuspendedStatus' -Value $matchGoogle.SuspendedStatus -Force
                $item | Add-Member -MemberType NoteProperty -Name 'GoogleCurrentLicenses' -Value $matchGoogle.User.CurrentLicenses -Force
                $googleData.LookupByID[$item.personID] = $matchGoogle.User
            }
        }
        #endregion Match PersonIDs




        Write-Log -Message "######## Phase: Plan Changes ########"

        #region AD Processing Lists
        if ($IDConfig.AD.enabled -eq $true) {
            #Org Units to Create
            $ADOrgUnitsForProcessing = Get-ADOrgUnitsForProcessing -UserList $sourceData -CurrentOrgUnits $adData.OrgUnits
            #Users to Deactivate
            $ADUsersToDeactivate = Get-ADUsersToDeactivate -UserList $sourceData
            #Users to Update
            $ADUsersToUpdate = Get-ADUsersToUpdate -UserList $sourceData -LookupByID $adData.LookupByID -CurrentADUsers $adData.Users
            #Users to Create
            $ADUsersToCreate = Get-ADUsersToCreate -UserList $sourceData -CurrentADUsers $adData.Users
            #Groups to Update
            if ($IDConfig.AD.enableGroupProcessing -eq $true -or $IDConfig.AD.enableGroupProcessingWhatIf -eq $true) {
                $ADUserGroupsToUpdate = Get-ADUserGroupsToUpdate -UserList $sourceData -CurrentADGroups $adData.Groups
            }
        }
        #endregion AD Processing Lists




        #region Google Processing Lists
        if ($IDConfig.Google.enabled -eq $true) {
            #Org Units to Create
            $GoogleOrgUnitsForProcessing = Get-GoogleOrgUnitsForProcessing -UserList $sourceData -CurrentOrgUnits $googleData.OrgUnits.orgUnitPath
            #Users to Update
            $GoogleUsersToUpdate = Get-GoogleUsersToUpdate -UserList $sourceData -LookupByID $googleData.LookupByID -GoogleUsers $googleData.Users
            #Users to Deactivate
            $GoogleUsersToDeactivate = Get-GoogleUsersToDeactivate -UserList $sourceData
            #Users to Create
            $GoogleUsersToCreate = Get-GoogleUsersToCreate -UserList $sourceData -GoogleUsers $googleData.Users
            #Groups to Update
            if ($IDConfig.Google.enableGroupProcessing -eq $true -or $IDConfig.Google.enableGroupProcessingWhatIf -eq $true) {
                $GoogleUserGroupsToUpdate = Get-GoogleUserGroupsToUpdate -UserList $sourceData -GoogleGroups $googleData.Groups
            }
        }
        #endregion Google Processing Lists




        #region Change Threshold Safety Check
        # Guard against a broken source feed mass-changing a directory: if the proposed lifecycle
        # changes exceed a percentage of the existing managed (root-OU) population, abort before any
        # writes. Bypassed by ChangeThreshold.Enabled = $false in config or the -SkipChangeThreshold switch.
        if ($IDConfig.ContainsKey('ChangeThreshold') -and $IDConfig.ChangeThreshold.Enabled -eq $true) {
            $thresholdResults = [System.Collections.Generic.List[object]]::new()

            if ($IDConfig.AD.enabled -eq $true) {
                $adManagedPopulation = @($adData.Users | Where-Object { $_.DistinguishedName -like "*,$($IDConfig.AD.userRootOU)" }).Count
                # Count distinct affected users: a single user needing update+rename+move is one change,
                # not three (so the ratio is comparable to Google's per-user count). CN uniquely identifies the user.
                # Wrap each list in @() so a single-element list (scalar string) concatenates as an
                # array rather than via string addition.
                $adModifiedUsers = [System.Collections.Generic.HashSet[string]]::new(
                    [string[]]@(@($ADUsersToUpdate.UpdateList.CN) + @($ADUsersToUpdate.RenameList.CN) + @($ADUsersToUpdate.MoveList.CN) | Where-Object { $_ })
                )
                $adChangeCount = @($ADUsersToCreate).Count + @($ADUsersToDeactivate).Count + $adModifiedUsers.Count
                $thresholdResults.Add( (Test-IDBridgeChangeThreshold -Directory 'AD' -ChangeCount $adChangeCount -PopulationCount $adManagedPopulation -ThresholdPercent $IDConfig.ChangeThreshold.Percentage) )
            }

            if ($IDConfig.Google.enabled -eq $true) {
                $googleManagedPopulation = @($googleData.Users | Where-Object { $_.orgUnitPath -eq $IDConfig.Google.userRootOU -or $_.orgUnitPath -like "$($IDConfig.Google.userRootOU)/*" }).Count
                $googleChangeCount = @($GoogleUsersToCreate).Count + @($GoogleUsersToDeactivate).Count + @($GoogleUsersToUpdate).Count
                $thresholdResults.Add( (Test-IDBridgeChangeThreshold -Directory 'Google' -ChangeCount $googleChangeCount -PopulationCount $googleManagedPopulation -ThresholdPercent $IDConfig.ChangeThreshold.Percentage) )
            }

            $breaches = @($thresholdResults | Where-Object { $_.Exceeded })
            if ($breaches.Count -gt 0) {
                $breachSummary = ($breaches | ForEach-Object { "$($_.Directory) $($_.Percent)%" }) -join ', '
                Write-Log -Message "Change threshold exceeded ($breachSummary > $($IDConfig.ChangeThreshold.Percentage)%). Aborting run before any writes. Set ChangeThreshold.Enabled = `$false or run with -SkipChangeThreshold to override." -Level Error
                Throw "Change threshold exceeded: $breachSummary (limit $($IDConfig.ChangeThreshold.Percentage)%)."
            }
        }
        #endregion Change Threshold Safety Check




        if ($IDConfig.Debug.readOnly -eq $true) {
            Write-Log -Message "######## Phase: Apply Changes (skipped - ReadOnly mode) ########"
        } else {
            Write-Log -Message "######## Phase: Apply Changes ########"
        }

        #region Process AD Changes
        if ($IDConfig.AD.enabled -eq $true -and $IDConfig.Debug.readOnly -eq $false) {
            #Create Org Units (Get-ADOrgUnitsForProcessing already returns these deduped and parents-first)
            foreach ($item in $ADOrgUnitsForProcessing) {
                try {
                    New-IDBridgeADOrgUnit -OrgUnit $item
                }
                catch {
                    Write-Log -Message "AD: Error Creating Org Unit. Please check RunAS user permisisons in AD or Detailed Error for more information" -Level Error
                    Throw
                }
            }

            #Disable Users
            foreach ($item in $ADUsersToDeactivate) {
                try {
                    #Disable-IDBridgeADUser returns the ErrorRecord on failure instead of throwing
                    $disableResult = Disable-IDBridgeADUser -User $item -GroupRemovalProcessingStatus $IDConfig.AD.enableGroupProcessingTrash
                    Add-IDBridgeWriteResult -Directory AD -Action Deactivate -PersonID $item.PersonID -Target $item.UPN -Success ($null -eq $disableResult) -ErrorMessage "$($disableResult)"
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                    Add-IDBridgeWriteResult -Directory AD -Action Deactivate -PersonID $item.PersonID -Target $item.UPN -Success $false -ErrorMessage $_.Exception.Message
                }
            }


            #Update Users
            foreach ($item in $ADUsersToUpdate.UpdateList) {
                try {
                    Write-Log -Message "AD: Applying: Updating User: $($item.CN) Properties: $($item.splat | ConvertTo-Json -Compress)"
                    $itemSplat = $item.splat
                    Set-ADUser @itemSplat -ErrorAction Stop
                    Add-IDBridgeWriteResult -Directory AD -Action Update -PersonID $item.PersonID -Target $item.CN -Success $true
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                    Add-IDBridgeWriteResult -Directory AD -Action Update -PersonID $item.PersonID -Target $item.CN -Success $false -ErrorMessage $_.Exception.Message
                }
            }

            #Rename Users
            foreach ($item in $ADUsersToUpdate.RenameList) {
                try {
                    Write-Log -Message "AD: Applying: Renaming User: $($item.CN) to $($item.NewName)"
                    Set-ADUser -Identity $item.ADUserID -Division (Get-Date -format yyyy-MM-dd-HH:mm)
                    Rename-ADObject -Identity $item.ADUserID -NewName $item.NewName -ErrorAction Stop
                    Add-IDBridgeWriteResult -Directory AD -Action Rename -PersonID $item.PersonID -Target $item.NewName -Success $true
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                    Add-IDBridgeWriteResult -Directory AD -Action Rename -PersonID $item.PersonID -Target $item.NewName -Success $false -ErrorMessage $_.Exception.Message
                }
            }

            #Move Users
            foreach ($item in $ADUsersToUpdate.MoveList) {
                try {
                    Write-Log -Message "AD: Applying: Moving User: $($item.CN) to $($item.NewOrgUnit)"
                    Set-ADUser -Identity $item.ADUserID -Division (Get-Date -format yyyy-MM-dd-HH:mm)
                    Move-ADObject -Identity $item.ADUserID -TargetPath $item.NewOrgUnit -ErrorAction Stop
                    Add-IDBridgeWriteResult -Directory AD -Action Move -PersonID $item.PersonID -Target $item.CN -Success $true
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                    Add-IDBridgeWriteResult -Directory AD -Action Move -PersonID $item.PersonID -Target $item.CN -Success $false -ErrorMessage $_.Exception.Message
                }
            }

            #Create Users
            foreach ($item in $ADUsersToCreate) {
                $itemCreated = $false
                try {
                    Write-Log -Message "AD: Applying: Creating User: $($item.PersonID) Properties: $($item.splat | ConvertTo-Json -Compress)"
                    $itemSplat = $item.splat
                    $newUser = New-ADUser @itemSplat -ErrorAction Stop
                    #Recorded before the GUID match-back below, which can fail independently of the create
                    $itemCreated = $true
                    Add-IDBridgeWriteResult -Directory AD -Action Create -PersonID $item.PersonID -Target $itemSplat.UserPrincipalName -Success $true

                    #Add the GUID to the data object
                    ($sourceData | Where-Object UPN -eq $itemSplat.UserPrincipalName).ADCurrentUserID = $newUser.ObjectGUID

                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                    if (-not $itemCreated) {
                        Add-IDBridgeWriteResult -Directory AD -Action Create -PersonID $item.PersonID -Target $itemSplat.UserPrincipalName -Success $false -ErrorMessage $_.Exception.Message
                    }
                }
            }

            #Process Group Membership (enableGroupProcessingWhatIf suppresses the writes - the change lists were already logged above)
            if ($IDConfig.AD.enableGroupProcessing -eq $true -and $IDConfig.AD.enableGroupProcessingWhatIf -eq $true) {
                Write-Log -Message "AD: Group processing WhatIf is enabled - group changes were logged but NOT applied."
            }
            if ($IDConfig.AD.enableGroupProcessing -eq $true -and $IDConfig.AD.enableGroupProcessingWhatIf -ne $true) {
                #If there are users to create, refresh the group membership updates to ensure any new users are included in the group processing
                if ($ADUsersToCreate.Count -gt 0) {
                    #Refresh AD User Groups to Update List to include newly created users
                    Write-Log -Message "AD: Refreshing User Groups to Update List to include newly created users." -Level Trace

                    $ADUserGroupsToUpdate = Get-ADUserGroupsToUpdate -UserList $sourceData -CurrentADGroups $adData.Groups
                }
                #Process Group Membership Add
                foreach ($item in $ADUserGroupsToUpdate.Add) {
                    foreach ($group in $item.Groups) {
                        try {
                            Write-Log -Message "AD: Applying: Adding Group: $group to $($item.PersonID)"
                            Add-ADPrincipalGroupMembership -Identity $item.ADCurrentUserID -MemberOf $group
                            Add-IDBridgeWriteResult -Directory AD -Action GroupAdd -PersonID $item.PersonID -Target $group -Success $true
                        }
                        catch {
                            Write-Log -Message ($_.Exception.Message) -Level Error
                            Add-IDBridgeWriteResult -Directory AD -Action GroupAdd -PersonID $item.PersonID -Target $group -Success $false -ErrorMessage $_.Exception.Message
                        }
                    }
                }

                #Process Group Membership Remove
                if ($IDConfig.AD.enableGroupProcessingRemove -eq $true) {
                    foreach ($item in $ADUserGroupsToUpdate.Remove) {
                        foreach ($group in $item.Groups) {
                            try {
                                Write-Log -Message "AD: Applying: Removing Group: $group from $($item.PersonID)"
                                Remove-ADGroupMember -Identity $group -Members $item.ADCurrentUserID -Confirm:$false
                                Add-IDBridgeWriteResult -Directory AD -Action GroupRemove -PersonID $item.PersonID -Target $group -Success $true
                            }
                            catch {
                                Write-Log -Message ($_.Exception.Message) -Level Error
                                Add-IDBridgeWriteResult -Directory AD -Action GroupRemove -PersonID $item.PersonID -Target $group -Success $false -ErrorMessage $_.Exception.Message
                            }
                        }
                    }
                }
            }
        }
        #endregion Process AD Changes




        #region Process Google Changes
        if ($IDConfig.Google.enabled -eq $true -and $IDConfig.Debug.readOnly -eq $false) {
            #Create Org Units
            foreach ($item in $GoogleOrgUnitsForProcessing) {
                try {
                    New-IDBridgeGoogleOrgUnit -OrgUnit $item 
                }
                catch {
                    Write-Log -Message "Google: Error Creating Org Unit. Please check API permissions in Google or Detailed Error for more information" -Level Error
                    Throw
                }
            }

            #Disable Users (archive + move to trash sent as one batch; groups/licenses follow per user)
            #Archiving (not suspending) self-releases the base Education Fundamentals license;
            #paid add-on licenses are removed below. Pre-archive suspended users are grandfathered.
            $googleBatchRequests = @()
            $identityMap = @{}
            foreach ($item in $GoogleUsersToDeactivate) {
                try {
                    Write-Log -Message ("Google: Applying: Archiving account for $($item.UPN)")
                    Write-Log -Message  ("Google: Applying: Moving account to trash: $($item.UPN)")
                    $deactivateSplat = @{
                        GoogleUserID = $item.GoogleCurrentUserID
                        OrgUnitPath  = $item.GoogleOrganizationalUnitTrash
                        Archived     = 'true'
                    }

                    #Persist the personID link on accounts matched by name - the update list only covers active users, so without this the account is re-matched every run
                    if ($item.personID -notin $item.GoogleObject.externalIds.value) {
                        $deactivateSplat['PersonID'] = $item.personID
                    }

                    $googleBatchRequests += Update-IDBridgeGoogleUser @deactivateSplat -AsBatchRequest
                    $identityMap["$($item.GoogleCurrentUserID)"] = @{ PersonID = $item.personID; Target = $item.UPN }
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                    Add-IDBridgeWriteResult -Directory Google -Action Deactivate -PersonID $item.personID -Target $item.UPN -Success $false -ErrorMessage $_.Exception.Message
                }
            }
            if ($googleBatchRequests.Count -gt 0) {
                $batchResponses = Invoke-GoogleBatchRequest -Requests $googleBatchRequests
                Add-IDBridgeGoogleBatchResult -Action Deactivate -Requests $googleBatchRequests -Responses $batchResponses -IdentityMap $identityMap
            }

            #Strip group memberships on deactivate (batched) and remove licenses
            $googleBatchRequests = @()
            $identityMap = @{}
            foreach ($item in $GoogleUsersToDeactivate) {
                if ($IDConfig.Google.enableGroupProcessing -eq $true -and $IDConfig.Google.enableGroupProcessingTrash -eq $true) {
                    foreach ($group in $item.GoogleCurrentGroups) {
                        try {
                            Write-Log -Message ("Google: Applying: Removing Group: $group from $($item.personID)")
                            $googleBatchRequests += Update-GoogleGroupMembers -GroupEmail $group -PersonID $item.GoogleCurrentUserID -UpdateType "Remove" -AsBatchRequest -ContentId "$($item.personID)|$group"
                            $identityMap["$($item.personID)|$group"] = @{ PersonID = $item.personID; Target = $group }
                        }
                        catch {
                            Write-Log -Message ($_.Exception.Message) -Level Error
                            Add-IDBridgeWriteResult -Directory Google -Action GroupRemove -PersonID $item.personID -Target $group -Success $false -ErrorMessage $_.Exception.Message
                        }
                    }
                }

                #Remove licenses (full deactivate path only, never on ForceDisable updates; on by default)
                if ($IDConfig.Google.enableLicenseRemoval -ne $false) {
                    Remove-IDBridgeGoogleUserLicense -UserEmail $item.GoogleObject.primaryEmail -Assignments $item.GoogleCurrentLicenses
                }
            }
            if ($googleBatchRequests.Count -gt 0) {
                $batchResponses = Invoke-GoogleBatchRequest -Requests $googleBatchRequests
                Add-IDBridgeGoogleBatchResult -Action GroupRemove -Requests $googleBatchRequests -Responses $batchResponses -IdentityMap $identityMap
            }

            #Update, Move, Rename Users (batched; any RemoveAlias pre-step runs immediately at collect time)
            $googleBatchRequests = @()
            $identityMap = @{}
            foreach ($item in $GoogleUsersToUpdate) {
                try {
                    Write-Log -Message "Google: Applying: Updating User: $($item.UPN) Properties: $($item.Splat | ConvertTo-Json -Compress)"
                    $itemSplat = $item.splat
                    $itemRequest = Update-IDBridgeGoogleUser @itemSplat -AsBatchRequest

                    #A failed RemoveAlias pre-step returns an ErrorRecord instead of a descriptor - skip those
                    if ($itemRequest -is [hashtable]) {
                        $googleBatchRequests += $itemRequest
                        $identityMap["$($itemRequest.ContentId)"] = @{ PersonID = $item.PersonID; Target = $item.UPN }
                    } else {
                        Add-IDBridgeWriteResult -Directory Google -Action Update -PersonID "$($item.PersonID)" -Target $item.UPN -Success $false -ErrorMessage "RemoveAlias pre-step failed: $($itemRequest)"
                    }
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                    Add-IDBridgeWriteResult -Directory Google -Action Update -PersonID "$($item.PersonID)" -Target $item.UPN -Success $false -ErrorMessage $_.Exception.Message
                }
            }
            if ($googleBatchRequests.Count -gt 0) {
                $batchResponses = Invoke-GoogleBatchRequest -Requests $googleBatchRequests
                Add-IDBridgeGoogleBatchResult -Action Update -Requests $googleBatchRequests -Responses $batchResponses -IdentityMap $identityMap
            }

            #Create Users (batched; new Google IDs are matched back by primaryEmail from each batch response)
            $googleBatchRequests = @()
            $identityMap = @{}
            foreach ($item in $GoogleUsersToCreate) {
                try {
                    Write-Log -Message "Google: Applying: Creating User: $($item.UPN) Properties: $($item.Splat | ConvertTo-Json -Compress)"
                    $itemSplat = $item.splat
                    $googleBatchRequests += New-IDBridgeGoogleUser @itemSplat -AsBatchRequest -ErrorAction Stop
                    $identityMap["$($item.UPN)"] = @{ PersonID = $item.PersonID; Target = $item.UPN }
                }
                catch {
                    Write-Log -Message ($_.Exception.Message) -Level Error
                    Add-IDBridgeWriteResult -Directory Google -Action Create -PersonID "$($item.PersonID)" -Target $item.UPN -Success $false -ErrorMessage $_.Exception.Message
                }
            }
            if ($googleBatchRequests.Count -gt 0) {
                $newUserResponses = Invoke-GoogleBatchRequest -Requests $googleBatchRequests
                Add-IDBridgeGoogleBatchResult -Action Create -Requests $googleBatchRequests -Responses $newUserResponses -IdentityMap $identityMap

                #Add the Google IDs to the data objects
                foreach ($newUserResponse in $newUserResponses | Where-Object { $_.StatusCode -lt 400 -and $_.Body.id }) {
                    ($sourceData | Where-Object UPN -eq $newUserResponse.Body.primaryEmail).GoogleCurrentUserID = $newUserResponse.Body.id
                }
            }

            #Process Group Membership (enableGroupProcessingWhatIf suppresses the writes - the change lists were already logged above)
            if ($IDConfig.Google.enableGroupProcessing -eq $true -and $IDConfig.Google.enableGroupProcessingWhatIf -eq $true) {
                Write-Log -Message "Google: Group processing WhatIf is enabled - group changes were logged but NOT applied."
            }
            if ($IDConfig.Google.enableGroupProcessing -eq $true -and $IDConfig.Google.enableGroupProcessingWhatIf -ne $true) {
                if ($GoogleUsersToCreate.Count -gt 0) {
                    #Refresh Google User Groups to Update List to include newly created users
                    Write-Log -Message "Google: Refreshing User Groups to Update List to include newly created users." -Level Trace
                    
                    $GoogleUserGroupsToUpdate = Get-GoogleUserGroupsToUpdate -UserList $sourceData -GoogleGroups $googleData.Groups
                }

                #Process Group Membership Add (batched; adds and removes stay in separate batches since batch execution order is not guaranteed)
                $googleBatchRequests = @()
                $identityMap = @{}
                foreach ($item in $GoogleUserGroupsToUpdate.Add) {
                    foreach ($group in $item.Groups) {
                        try {
                            Write-Log -Message "Google: Applying: Adding Group: $group to $($item.PersonID)"
                            $googleBatchRequests += Update-GoogleGroupMembers -GroupEmail ($googleData.Groups | Where-Object {$_.name -eq $group}).email -PersonID $item.GoogleCurrentUserID -UpdateType "Add" -AsBatchRequest -ContentId "$($item.PersonID)|$group"
                            $identityMap["$($item.PersonID)|$group"] = @{ PersonID = $item.PersonID; Target = $group }
                        }
                        catch {
                            Write-Log -Message ($_.Exception.Message) -Level Error
                            Add-IDBridgeWriteResult -Directory Google -Action GroupAdd -PersonID $item.PersonID -Target $group -Success $false -ErrorMessage $_.Exception.Message
                        }
                    }
                }
                if ($googleBatchRequests.Count -gt 0) {
                    $batchResponses = Invoke-GoogleBatchRequest -Requests $googleBatchRequests
                    Add-IDBridgeGoogleBatchResult -Action GroupAdd -Requests $googleBatchRequests -Responses $batchResponses -IdentityMap $identityMap
                }

                #Process Group Membership Remove (batched)
                if ($IDConfig.Google.enableGroupProcessingRemove -eq $true) {
                    $googleBatchRequests = @()
                    $identityMap = @{}
                    foreach ($item in $GoogleUserGroupsToUpdate.Remove) {
                        foreach ($group in $item.Groups) {
                            try {
                                Write-Log -Message "Google: Applying: Removing Group: $group from $($item.PersonID)"
                                $googleBatchRequests += Update-GoogleGroupMembers -GroupEmail $group -PersonID $item.GoogleCurrentUserID -UpdateType "Remove" -AsBatchRequest -ContentId "$($item.PersonID)|$group"
                                $identityMap["$($item.PersonID)|$group"] = @{ PersonID = $item.PersonID; Target = $group }
                            }
                            catch {
                                Write-Log -Message ($_.Exception.Message) -Level Error
                                Add-IDBridgeWriteResult -Directory Google -Action GroupRemove -PersonID $item.PersonID -Target $group -Success $false -ErrorMessage $_.Exception.Message
                            }
                        }
                    }
                    if ($googleBatchRequests.Count -gt 0) {
                        $batchResponses = Invoke-GoogleBatchRequest -Requests $googleBatchRequests
                        Add-IDBridgeGoogleBatchResult -Action GroupRemove -Requests $googleBatchRequests -Responses $batchResponses -IdentityMap $identityMap
                    }
                }
            }
        }
        #endregion Process Google Changes




        #region Run Summary
        # Per-directory totals from the computed change lists. In ReadOnly these are PROPOSED (nothing
        # was written); otherwise APPLIED (best-effort - any per-user failures were logged inline above).
        $summaryMode = if ($IDConfig.Debug.readOnly -eq $true) { 'PROPOSED (ReadOnly)' } else { 'APPLIED' }
        Write-Log -Message "Run Summary [$summaryMode]:"

        # Group-update lists are only assigned when group processing is on; .Where filters the lone
        # $null that @() wraps when the variable is unset, so a disabled group sync reports 0 (not 1).
        if ($IDConfig.AD.enabled -eq $true) {
            Write-Log -Message ("AD: Create={0} Update={1} Rename={2} Move={3} Deactivate={4} GroupAdd={5} GroupRemove={6}" -f `
                @($ADUsersToCreate).Count, @($ADUsersToUpdate.UpdateList).Count, @($ADUsersToUpdate.RenameList).Count, `
                @($ADUsersToUpdate.MoveList).Count, @($ADUsersToDeactivate).Count, `
                @($ADUserGroupsToUpdate.Add.Groups).Where({ $null -ne $_ }).Count, @($ADUserGroupsToUpdate.Remove.Groups).Where({ $null -ne $_ }).Count)
        }

        if ($IDConfig.Google.enabled -eq $true) {
            Write-Log -Message ("Google: Create={0} Update={1} Deactivate={2} GroupAdd={3} GroupRemove={4}" -f `
                @($GoogleUsersToCreate).Count, @($GoogleUsersToUpdate).Count, @($GoogleUsersToDeactivate).Count, `
                @($GoogleUserGroupsToUpdate.Add.Groups).Where({ $null -ne $_ }).Count, @($GoogleUserGroupsToUpdate.Remove.Groups).Where({ $null -ne $_ }).Count)
        }

        if ($thresholdResults) {
            foreach ($result in $thresholdResults) {
                $pct = if ($result.Skipped) { 'skipped (no managed population)' } else { "$($result.Percent)% of $($result.PopulationCount) (limit $($IDConfig.ChangeThreshold.Percentage)%)" }
                Write-Log -Message "$($result.Directory) change volume: $pct"
            }
        }
        #endregion Run Summary




    } catch {
        $runError = $_

        # Write-Log needs an initialized config; if the failure happened before/at config load it would
        # throw again here, so fall back to Write-Error in that case.
        try {
            Write-Log -Message "Invoke-IDBridge Failed" -Level Error
            Write-Log -Message ($_ | Out-String) -Level Error
        }
        catch {
            Write-Error "Invoke-IDBridge Failed: $($_.Exception.Message)"
        }
    } finally {
        # $IDConfig is only set once the config loads successfully; guard so a pre-config failure
        # doesn't throw out of the finally block.
        if ($IDConfig) {
            #region Build Run Result
            # One end-of-run report, consumed by both telemetry and the PostRun plugins. Built
            # defensively: on a failed run most change-list variables were never assigned, so every
            # list is wrapped @(...).Where({ $null -ne $_ }) to filter the lone $null @() wraps.
            $runResult = $null
            try {
                # Counts are ACTUAL applied outcomes, aggregated from the per-write results the
                # write phases recorded via Add-IDBridgeWriteResult. The old gates (ReadOnly,
                # group WhatIf, remove-off) are implicit now: nothing attempted = nothing
                # recorded, so those runs still report zeros.
                $appliedResults = if ($script:WriteResults) { @($script:WriteResults) } else { @() }
                $appliedOk = @($appliedResults | Where-Object { $_.Success })
                $runCounts = @{
                    Create      = @($appliedOk | Where-Object { $_.Action -eq 'Create' }).Count
                    # Update aggregates Update+Rename+Move, matching the historical definition
                    Update      = @($appliedOk | Where-Object { $_.Action -in @('Update', 'Rename', 'Move') }).Count
                    Deactivate  = @($appliedOk | Where-Object { $_.Action -eq 'Deactivate' }).Count
                    GroupAdd    = @($appliedOk | Where-Object { $_.Action -eq 'GroupAdd' }).Count
                    GroupRemove = @($appliedOk | Where-Object { $_.Action -eq 'GroupRemove' }).Count
                    Failed      = @($appliedResults | Where-Object { -not $_.Success }).Count
                }

                # See docs/plugins.md for the published schema. Grow this additively only - PostRun
                # plugins in the field depend on these property names.
                $runResult = [PSCustomObject]@{
                    SchemaVersion    = 1
                    ModuleVersion    = "$($MyInvocation.MyCommand.Module.Version)"
                    Success          = (-not $runError)
                    RunError         = $runError
                    RunStart         = $runStart
                    RunEnd           = (Get-Date)
                    DurationSeconds  = [int]((Get-Date) - $runStart).TotalSeconds
                    ReadOnly         = ($IDConfig.Debug.readOnly -eq $true)
                    TestRun          = ($IDConfig.Debug.testRun -eq $true)
                    Counts           = [PSCustomObject]@{
                        Managed     = @($sourceData).Where({ $null -ne $_ }).Count
                        Create      = $runCounts.Create
                        Update      = $runCounts.Update
                        Deactivate  = $runCounts.Deactivate
                        GroupAdd    = $runCounts.GroupAdd
                        GroupRemove = $runCounts.GroupRemove
                        Failed      = $runCounts.Failed
                    }
                    Applied          = $appliedResults
                    SourceData       = @($sourceData).Where({ $null -ne $_ })
                    ThresholdResults = @($thresholdResults).Where({ $null -ne $_ })
                    AD               = [PSCustomObject]@{
                        Enabled           = ($IDConfig.AD.enabled -eq $true)
                        UsersToCreate     = @($ADUsersToCreate).Where({ $null -ne $_ })
                        UsersToUpdate     = $ADUsersToUpdate
                        UsersToDeactivate = @($ADUsersToDeactivate).Where({ $null -ne $_ })
                        GroupsToUpdate    = $ADUserGroupsToUpdate
                        OrgUnitsToCreate  = @($ADOrgUnitsForProcessing).Where({ $null -ne $_ })
                    }
                    Google           = [PSCustomObject]@{
                        Enabled           = ($IDConfig.Google.enabled -eq $true)
                        UsersToCreate     = @($GoogleUsersToCreate).Where({ $null -ne $_ })
                        UsersToUpdate     = @($GoogleUsersToUpdate).Where({ $null -ne $_ })
                        UsersToDeactivate = @($GoogleUsersToDeactivate).Where({ $null -ne $_ })
                        GroupsToUpdate    = $GoogleUserGroupsToUpdate
                        OrgUnitsToCreate  = @($GoogleOrgUnitsForProcessing).Where({ $null -ne $_ })
                    }
                }
            }
            catch {
                Write-Log -Message "Run Result: Build failed ($($_.Exception.GetType().Name)) - telemetry and PostRun plugins skipped." -Level Warn
            }
            #endregion Build Run Result

            #region Telemetry
            # Usage telemetry (see PRIVACY.md) - fully self-contained: any failure here is swallowed
            # and logged locally so it can never mask the run's real outcome or throw out of finally.
            try {
                if ($runResult) {
                    $telemetrySplat = @{
                        Success           = $runResult.Success
                        DurationSeconds   = $runResult.DurationSeconds
                        ManagedCount      = $runResult.Counts.Managed
                        CreateCount       = $runResult.Counts.Create
                        UpdateCount       = $runResult.Counts.Update
                        DeactivateCount   = $runResult.Counts.Deactivate
                        GroupAddCount     = $runResult.Counts.GroupAdd
                        GroupRemoveCount  = $runResult.Counts.GroupRemove
                        WriteFailureCount = $runResult.Counts.Failed
                    }
                    if ($runError) { $telemetrySplat.RunError = $runError }

                    Send-IDBridgeTelemetry @telemetrySplat
                }
            }
            catch {
                Write-Log -Message "Telemetry: Skipped ($($_.Exception.GetType().Name))." -Level Trace
            }
            #endregion Telemetry

            #region PostRun Plugins
            # SecureStrings (account keys, passphrase API secrets) are scrubbed from the report
            # before any plugin sees it - the run is over, nothing downstream needs them. Isolated
            # like telemetry: a PostRun failure can never mask the run's real outcome.
            try {
                if ($runResult) {
                    Hide-IDBridgeSecureString -InputObject $runResult
                    Invoke-PostRunPlugins -RunResult $runResult
                }
            }
            catch {
                Write-Log -Message "PostRun Plugins: Skipped ($($_.Exception.GetType().Name))." -Level Warn
            }
            #endregion PostRun Plugins

            Write-Log -Message "######## End of Script Run: $((Get-Date -Format "yyyy-MM-dd-HH.mm.ss")) ########"

            if ($IDConfig.Logging.GoogleSheetLoggingEnabled) {
                Push-LogsToSheet -spreadsheetId $IDConfig.Logging.SheetID -sheetName 'Logs'
            }
        }
    }
}