Private/ConvertTo-NSPEntraSourceAnchorResult.ps1

function ConvertTo-NSPEntraSourceAnchorResult {
    <#
    .SYNOPSIS
        Works out the source-anchor strategy from Entra Connect's rules, connectors and global setting.
    .DESCRIPTION
        Offline: it only reads what it is given, so tests can feed it sanitized rule objects.
 
        Provenance is Verified only when every AD connector's enabled inbound user rules flow one
        recognized strategy and the global setting, when readable, does not contradict it. Scoped
        rules are not resolved per object, so rules that disagree stay Unverified rather than being
        settled by precedence. Anything unrecognized is Unverified: a wrong guess here would write a
        wrong immutable ID.
 
        Contact rules are left out: their objects are never paired, and Entra Connect keeps objectGUID
        for them whatever the user anchor is. Only source type contact is left out, so a source type
        nobody has seen still counts.
    #>

    [CmdletBinding()]
    param(
        [AllowEmptyCollection()][object[]]$Rule = @(),
        [AllowEmptyCollection()][object[]]$Connector = @(),
        [AllowEmptyCollection()][object[]]$GlobalParameter = @(),
        [string]$ComputerName,
        [string]$ModuleVersion
    )

    $reasons = [System.Collections.Generic.List[string]]::new()
    $notes = [System.Collections.Generic.List[string]]::new()
    $adConnectors = @{}
    foreach ($item in $Connector) {
        if ([string]$item.ConnectorTypeName -eq 'AD') { $adConnectors[[string]$item.Identifier] = [string]$item.Name }
    }
    if (-not $adConnectors.Count) { $reasons.Add('No AD connector was readable.') }

    $contactRules = 0
    $evidence = foreach ($item in $Rule) {
        if ($item.Disabled -or [string]$item.Direction -ne 'Inbound' -or [string]$item.TargetObjectType -ne 'person') { continue }
        $connectorId = [string]$item.Connector
        if (-not $adConnectors.ContainsKey($connectorId)) { continue }
        if ([string]$item.SourceObjectType -eq 'contact') { $contactRules++; continue }
        foreach ($mapping in @($item.AttributeFlowMappings)) {
            if ($null -eq $mapping -or [string]$mapping.Destination -notin @('sourceAnchorBinary', 'sourceAnchor')) { continue }
            $strategy = Get-NSPEntraAnchorFlowStrategy -Mapping $mapping
            if ($strategy -eq 'Derived') { continue }
            [pscustomobject]@{
                ConnectorName = $adConnectors[$connectorId]
                RuleName = [string]$item.Name
                Precedence = $item.Precedence
                SourceObjectType = [string]$item.SourceObjectType
                Destination = [string]$mapping.Destination
                FlowType = [string]$mapping.FlowType
                SourceAttribute = @($mapping.Source | Where-Object { $_ } | ForEach-Object { [string]$_ })
                Expression = [string]$mapping.Expression
                Strategy = $strategy
            }
        }
    }
    $evidence = @($evidence)
    if ($contactRules) { $notes.Add("$contactRules contact rule(s) left out: contacts are not paired.") }
    if (@($evidence | Where-Object { $_.Strategy -ne 'Unknown' -and $_.Expression -match 'msExchRecipientTypeDetails' }).Count) {
        $notes.Add('These rules give no anchor to a user with msExchRecipientTypeDetails 2. The inventory cannot see that attribute, and a write for such a user is not expected to link.')
    }

    $perConnector = foreach ($id in $adConnectors.Keys) {
        $name = $adConnectors[$id]
        $strategies = @($evidence | Where-Object ConnectorName -eq $name | ForEach-Object { $_.Strategy } | Sort-Object -Unique)
        if ($strategies.Count -eq 0) { $reasons.Add("Connector '$name': no enabled inbound rule flows the source anchor.") }
        elseif ($strategies.Count -gt 1) { $reasons.Add("Connector '$name': rules flow different anchors ($($strategies -join ', ')). Review precedence and scope in the Synchronization Rules Editor.") }
        elseif ($strategies[0] -eq 'Unknown') { $reasons.Add("Connector '$name': the anchor flow is not a recognized form. Review it in the Synchronization Rules Editor.") }
        [pscustomobject]@{ ConnectorName = $name; Strategy = if ($strategies.Count -eq 1) { $strategies[0] } else { 'Unknown' } }
    }
    $perConnector = @($perConnector)

    $distinct = @($perConnector | ForEach-Object { $_.Strategy } | Sort-Object -Unique)
    $strategy = if ($distinct.Count -eq 1) { $distinct[0] } else { 'Unknown' }
    if ($distinct.Count -gt 1) { $reasons.Add("Connectors use different anchors: $($distinct -join ', ').") }

    $setting = @($GlobalParameter | Where-Object { [string]$_.Name -eq 'Microsoft.SynchronizationOption.AnchorAttribute' })[0]
    $globalValue = if ($setting) { [string]$setting.Value } else { $null }
    if (-not $globalValue) {
        $notes.Add('The global anchor setting was not readable; the rules alone were used.')
    } elseif ($strategy -ne 'Unknown') {
        $expected = if ($strategy -eq 'ObjectGuid') { 'objectGUID' } else { 'mS-DS-ConsistencyGuid' }
        if ($globalValue -ne $expected) { $reasons.Add("The global setting names $globalValue, but the rules flow $strategy.") }
    }

    [pscustomobject]@{
        PSTypeName = 'NSP.M365.UserPairing.SourceAnchor'
        Provenance = if ($reasons.Count) { 'Unverified' } else { 'Verified' }
        Strategy = $strategy
        Reasons = @($reasons)
        Notes = @($notes)
        GlobalSetting = $globalValue
        Connectors = $perConnector
        Evidence = $evidence
        ComputerName = $ComputerName
        ModuleVersion = $ModuleVersion
        CollectedAtUtc = [datetime]::UtcNow.ToString('o')
    }
}