Modules/Core/20-Runtime.ps1

function Invoke-RangerCollectorExecution {
    param(
        [Parameter(Mandatory = $true)]
        [object]$Definition,

        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

        [Parameter(Mandatory = $true)]
        $CredentialMap,

        [Parameter(Mandatory = $true)]
        [string]$PackageRoot,

        # Issue #30: connectivity matrix from Get-RangerConnectivityMatrix.
        # When supplied, collectors whose transport surface is unreachable are skipped
        # with status 'skipped' rather than being attempted and failing mid-run.
        [System.Collections.IDictionary]$ConnectivityMatrix
    )

    $messages = New-Object System.Collections.Generic.List[string]
    $start = (Get-Date).ToUniversalTime().ToString('o')
    $status = 'success'
    $functionName = $Definition.FunctionName
    $arguments = @{
        Config        = $Config
        CredentialMap = $CredentialMap
        Definition    = $Definition
        PackageRoot   = $PackageRoot
    }

    try {
        # Issue #30: skip collector gracefully when connectivity matrix says its
        # required transport surface is not reachable from this runner.
        if ($ConnectivityMatrix -and (Get-Command -Name 'Test-RangerCollectorConnectivitySatisfied' -ErrorAction SilentlyContinue)) {
            $satisfied = Test-RangerCollectorConnectivitySatisfied -Definition $Definition -ConnectivityMatrix $ConnectivityMatrix
            if (-not $satisfied) {
                $skipSurface = $Definition.RequiredCredential
                $skipMsg = "Collector '$($Definition.Id)' skipped — $skipSurface transport unreachable (connectivity posture: $($ConnectivityMatrix.posture))."
                $messages.Add($skipMsg)
                $end = (Get-Date).ToUniversalTime().ToString('o')
                return @{
                    CollectorId     = $Definition.Id
                    Status          = 'skipped'
                    StartTimeUtc    = $start
                    EndTimeUtc      = $end
                    TargetScope     = @($Definition.RequiredTargets)
                    CredentialScope = $Definition.RequiredCredential
                    Messages        = @($messages)
                    Domains         = @{}
                    Topology        = $null
                    Relationships   = @()
                    Findings        = @()
                    Evidence        = @()
                    RawEvidence     = $null
                }
            }
        }

        if (-not (Get-Command -Name $functionName -ErrorAction SilentlyContinue)) {
            throw "Collector function '$functionName' is not available."
        }

        $result = & $functionName @arguments
        if (-not $result) {
            $status = 'not-applicable'
            $result = @{}
        }
        elseif ($result.Status) {
            $status = $result.Status
        }

        $messages.Add("Collector '$($Definition.Id)' completed with status '$status'.")
    }
    catch {
        $status = if ($Definition.Class -eq 'optional' -and [bool]$Config.behavior.skipUnavailableOptionalDomains) { 'skipped' } else { 'failed' }
        $messages.Add($_.Exception.Message)
        $result = @{
            Domains       = @{}
            Findings      = @(
                New-RangerFinding -Severity warning -Title "Collector $($Definition.Id) did not complete" -Description $_.Exception.Message -CurrentState $status -Recommendation 'Review target reachability, credentials, and required dependencies.'
            )
            Relationships = @()
            Evidence      = @()
        }
    }

    $end = (Get-Date).ToUniversalTime().ToString('o')
    return @{
        CollectorId     = $Definition.Id
        Status          = $status
        StartTimeUtc    = $start
        EndTimeUtc      = $end
        TargetScope     = @($Definition.RequiredTargets)
        CredentialScope = $Definition.RequiredCredential
        Messages        = @(@($messages) + @($result.Messages) | Where-Object { $null -ne $_ })
        Domains         = if ($result.Domains) { $result.Domains } else { @{} }
        Topology        = $result.Topology
        Relationships   = @($result.Relationships)
        Findings        = @($result.Findings)
        Evidence        = @($result.Evidence)
        RawEvidence     = $result.RawEvidence
    }
}

function New-RangerPackageRoot {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Config,

        [string]$OutputPathOverride,
        [string]$BasePath = (Get-Location).Path
    )

    $rootPath = if ($OutputPathOverride) { $OutputPathOverride } else { $Config.output.rootPath }
    $resolvedRoot = Resolve-RangerPath -Path $rootPath -BasePath $BasePath
    $packageName = '{0}-{1}-{2}' -f (Get-RangerSafeName -Value ($Config.environment.name)), (Get-RangerSafeName -Value $Config.output.mode), (Get-RangerTimestamp)
    $packageRoot = Join-Path -Path $resolvedRoot -ChildPath $packageName
    New-Item -ItemType Directory -Path $packageRoot -Force | Out-Null
    return $packageRoot
}

function Test-RangerDriftDictionaryLike {
    param(
        $Value
    )

    return $Value -is [System.Collections.IDictionary]
}

function Test-RangerDriftEnumerableLike {
    param(
        $Value
    )

    return $Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string]) -and -not ($Value -is [System.Collections.IDictionary])
}

function Get-RangerDriftItemIdentity {
    param(
        $Item
    )

    if (-not (Test-RangerDriftDictionaryLike -Value $Item)) {
        return $null
    }

    foreach ($propertyName in @('name', 'friendlyName', 'id', 'resourceId', 'uniqueId', 'node', 'path', 'driveLetter', 'interface', 'interfaceAlias', 'taskName', 'policyId', 'filePath', 'serialNumber')) {
        if ($Item.Contains($propertyName) -and -not [string]::IsNullOrWhiteSpace([string]$Item[$propertyName])) {
            return '{0}:{1}' -f $propertyName, [string]$Item[$propertyName]
        }
    }

    return $null
}

function Add-RangerDriftChangeRecord {
    param(
        [Parameter(Mandatory = $true)]
        [ref]$Changes,

        [Parameter(Mandatory = $true)]
        [string]$Domain,

        [Parameter(Mandatory = $true)]
        [string]$Path,

        [Parameter(Mandatory = $true)]
        [ValidateSet('added', 'removed', 'changed')]
        [string]$ChangeType,

        $BaselineValue,
        $CurrentValue
    )

    [void]$Changes.Value.Add([ordered]@{
        domain        = $Domain
        path          = $Path
        changeType    = $ChangeType
        baselineValue = $BaselineValue
        currentValue  = $CurrentValue
    })
}

function Compare-RangerDriftValue {
    param(
        $Baseline,
        $Current,
        [Parameter(Mandatory = $true)]
        [string]$Path,
        [Parameter(Mandatory = $true)]
        [string]$Domain,
        [Parameter(Mandatory = $true)]
        [ref]$Changes
    )

    if ($null -eq $Baseline -and $null -eq $Current) {
        return
    }

    if ($null -eq $Baseline) {
        Add-RangerDriftChangeRecord -Changes $Changes -Domain $Domain -Path $Path -ChangeType 'added' -BaselineValue $null -CurrentValue $Current
        return
    }

    if ($null -eq $Current) {
        Add-RangerDriftChangeRecord -Changes $Changes -Domain $Domain -Path $Path -ChangeType 'removed' -BaselineValue $Baseline -CurrentValue $null
        return
    }

    $baselineIsDictionary = Test-RangerDriftDictionaryLike -Value $Baseline
    $currentIsDictionary = Test-RangerDriftDictionaryLike -Value $Current
    $baselineIsEnumerable = Test-RangerDriftEnumerableLike -Value $Baseline
    $currentIsEnumerable = Test-RangerDriftEnumerableLike -Value $Current

    if (($baselineIsDictionary -and $currentIsEnumerable) -or ($baselineIsEnumerable -and $currentIsDictionary)) {
        if ($baselineIsDictionary -and -not $baselineIsEnumerable) {
            $normalizedBaseline = New-Object object[] 1
            $normalizedBaseline[0] = $Baseline
        }
        else {
            $normalizedBaseline = $Baseline
        }

        if ($currentIsDictionary -and -not $currentIsEnumerable) {
            $normalizedCurrent = New-Object object[] 1
            $normalizedCurrent[0] = $Current
        }
        else {
            $normalizedCurrent = $Current
        }

        Compare-RangerDriftValue -Baseline $normalizedBaseline -Current $normalizedCurrent -Path $Path -Domain $Domain -Changes $Changes
        return
    }

    if ($baselineIsDictionary -and $currentIsDictionary) {
        $keys = @($Baseline.Keys + $Current.Keys | Sort-Object -Unique)
        foreach ($key in $keys) {
            $childPath = if ([string]::IsNullOrWhiteSpace($Path)) { [string]$key } else { '{0}.{1}' -f $Path, $key }
            $baselineChild = if ($Baseline.Contains($key)) { $Baseline[$key] } else { $null }
            $currentChild = if ($Current.Contains($key)) { $Current[$key] } else { $null }
            Compare-RangerDriftValue -Baseline $baselineChild -Current $currentChild -Path $childPath -Domain $Domain -Changes $Changes
        }
        return
    }

    if ($baselineIsEnumerable -and $currentIsEnumerable) {
        $baselineItems = @($Baseline)
        $currentItems = @($Current)
        $identityProperty = $null

        if ($baselineItems.Count -gt 0 -or $currentItems.Count -gt 0) {
            $combinedItems = @($baselineItems) + @($currentItems)
            $sampleItem = @($combinedItems | Where-Object { $_ -ne $null } | Select-Object -First 1)[0]
            $sampleIdentity = Get-RangerDriftItemIdentity -Item $sampleItem
            if ($sampleIdentity) {
                $identityProperty = ($sampleIdentity -split ':', 2)[0]
            }
        }

        if ($identityProperty) {
            $baselineMap = [ordered]@{}
            foreach ($item in $baselineItems) {
                $identity = Get-RangerDriftItemIdentity -Item $item
                if ($identity) {
                    $baselineMap[$identity] = $item
                }
            }

            $currentMap = [ordered]@{}
            foreach ($item in $currentItems) {
                $identity = Get-RangerDriftItemIdentity -Item $item
                if ($identity) {
                    $currentMap[$identity] = $item
                }
            }

            $keys = @($baselineMap.Keys + $currentMap.Keys | Sort-Object -Unique)
            foreach ($key in $keys) {
                $label = $key.Substring($identityProperty.Length + 1)
                $childPath = '{0}[{1}]' -f $Path, $label
                $baselineChild = if ($baselineMap.Contains($key)) { $baselineMap[$key] } else { $null }
                $currentChild = if ($currentMap.Contains($key)) { $currentMap[$key] } else { $null }
                Compare-RangerDriftValue -Baseline $baselineChild -Current $currentChild -Path $childPath -Domain $Domain -Changes $Changes
            }
            return
        }

        $baselineJson = $baselineItems | ConvertTo-Json -Depth 100 -Compress
        $currentJson = $currentItems | ConvertTo-Json -Depth 100 -Compress
        if ($baselineJson -ne $currentJson) {
            Add-RangerDriftChangeRecord -Changes $Changes -Domain $Domain -Path $Path -ChangeType 'changed' -BaselineValue $baselineItems -CurrentValue $currentItems
        }
        return
    }

    $baselineJson = $Baseline | ConvertTo-Json -Depth 20 -Compress
    $currentJson = $Current | ConvertTo-Json -Depth 20 -Compress
    if ($baselineJson -ne $currentJson) {
        Add-RangerDriftChangeRecord -Changes $Changes -Domain $Domain -Path $Path -ChangeType 'changed' -BaselineValue $Baseline -CurrentValue $Current
    }
}

function New-RangerDriftReport {
    param(
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$CurrentManifest,

        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$BaselineManifest,

        [Parameter(Mandatory = $true)]
        [string]$BaselineManifestPath
    )

    if ($BaselineManifest.run.schemaVersion -ne $CurrentManifest.run.schemaVersion) {
        return [ordered]@{
            status                = 'skipped'
            generatedAtUtc        = (Get-Date).ToUniversalTime().ToString('o')
            baselineManifestPath  = $BaselineManifestPath
            baselineSchemaVersion = $BaselineManifest.run.schemaVersion
            currentSchemaVersion  = $CurrentManifest.run.schemaVersion
            comparedDomains       = @()
            skippedReason         = "Baseline schema version '$($BaselineManifest.run.schemaVersion)' does not match current schema version '$($CurrentManifest.run.schemaVersion)'."
            summary               = [ordered]@{
                totalChanges = 0
                added        = 0
                removed      = 0
                changed      = 0
                domainCounts = @()
            }
            changes               = @()
        }
    }

    $domainsToCompare = @('clusterNode', 'hardware', 'storage', 'networking', 'virtualMachines', 'azureIntegration', 'identitySecurity')
    $changes = New-Object System.Collections.ArrayList
    foreach ($domainName in $domainsToCompare) {
        $baselineDomain = if ($BaselineManifest.domains.Contains($domainName)) { $BaselineManifest.domains[$domainName] } else { $null }
        $currentDomain = if ($CurrentManifest.domains.Contains($domainName)) { $CurrentManifest.domains[$domainName] } else { $null }
        Compare-RangerDriftValue -Baseline $baselineDomain -Current $currentDomain -Path $domainName -Domain $domainName -Changes ([ref]$changes)
    }

    $changeList = @($changes)
    $domainCounts = @(
        $changeList |
            Group-Object domain |
            Sort-Object Name |
            ForEach-Object {
                [ordered]@{
                    domain   = $_.Name
                    added    = @($_.Group | Where-Object { $_.changeType -eq 'added' }).Count
                    removed  = @($_.Group | Where-Object { $_.changeType -eq 'removed' }).Count
                    changed  = @($_.Group | Where-Object { $_.changeType -eq 'changed' }).Count
                    total    = $_.Count
                }
            }
    )

    [ordered]@{
        status                = 'generated'
        generatedAtUtc        = (Get-Date).ToUniversalTime().ToString('o')
        baselineManifestPath  = $BaselineManifestPath
        baselineSchemaVersion = $BaselineManifest.run.schemaVersion
        currentSchemaVersion  = $CurrentManifest.run.schemaVersion
        comparedDomains       = @($domainsToCompare)
        skippedReason         = $null
        summary               = [ordered]@{
            totalChanges = $changeList.Count
            added        = @($changeList | Where-Object { $_.changeType -eq 'added' }).Count
            removed      = @($changeList | Where-Object { $_.changeType -eq 'removed' }).Count
            changed      = @($changeList | Where-Object { $_.changeType -eq 'changed' }).Count
            domainCounts = $domainCounts
        }
        changes               = $changeList
    }
}

function Write-RangerJsonArtifact {
    param(
        [Parameter(Mandatory = $true)]
        [string]$PackageRoot,

        [Parameter(Mandatory = $true)]
        [string]$RelativePath,

        [Parameter(Mandatory = $true)]
        $Content
    )

    $artifactPath = Join-Path -Path $PackageRoot -ChildPath $RelativePath
    New-Item -ItemType Directory -Path (Split-Path -Parent $artifactPath) -Force | Out-Null
    $Content | ConvertTo-Json -Depth 100 | Set-Content -Path $artifactPath -Encoding UTF8
    return $artifactPath
}

function New-RangerRunStatus {
    param(
        [Parameter(Mandatory = $true)]
        [bool]$Unattended,

        [string]$Status = 'success',
        [string]$ErrorMessage,
        [System.Collections.IDictionary]$Manifest,
        [string]$ManifestPath,
        [string]$LogPath,
        [string]$DriftStatus = 'not-requested'
    )

    $collectorEntries = if ($Manifest -and $Manifest.collectors) { @($Manifest.collectors.Values) } else { @() }
    [ordered]@{
        status          = $Status
        unattended      = $Unattended
        generatedAtUtc  = (Get-Date).ToUniversalTime().ToString('o')
        manifestPath    = $ManifestPath
        logPath         = $LogPath
        driftStatus     = $DriftStatus
        errorMessage    = $ErrorMessage
        collectorCounts = [ordered]@{
            total         = $collectorEntries.Count
            success       = @($collectorEntries | Where-Object { $_.status -eq 'success' }).Count
            partial       = @($collectorEntries | Where-Object { $_.status -eq 'partial' }).Count
            failed        = @($collectorEntries | Where-Object { $_.status -eq 'failed' }).Count
            skipped       = @($collectorEntries | Where-Object { $_.status -eq 'skipped' }).Count
            notApplicable = @($collectorEntries | Where-Object { $_.status -eq 'not-applicable' }).Count
        }
    }
}

function Invoke-RangerDiscoveryRuntime {
    param(
        [string]$ConfigPath,
        $ConfigObject,
        [string]$OutputPath,
        [hashtable]$CredentialOverrides,
        [string[]]$IncludeDomains,
        [string[]]$ExcludeDomains,
        [switch]$NoRender,
        [hashtable]$StructuralOverrides,
        [switch]$AllowInteractiveInput,
        [switch]$Unattended,
        [string]$BaselineManifestPath
    )

    $config = Import-RangerConfiguration -ConfigPath $ConfigPath -ConfigObject $ConfigObject
    $config = Set-RangerStructuralOverrides -Config $config -StructuralOverrides $StructuralOverrides

    if ($IncludeDomains) {
        $config.domains.include = @($IncludeDomains)
    }

    if ($ExcludeDomains) {
        $config.domains.exclude = @($ExcludeDomains)
    }

    if ($Unattended) {
        $config.behavior.promptForMissingCredentials = $false
    }

    if ($AllowInteractiveInput) {
        $config = Invoke-RangerInteractiveInput -Config $config
    }

    $script:RangerLogLevel = Resolve-RangerLogLevel -Level $(if ($config.behavior.logLevel) { $config.behavior.logLevel } else { 'info' })
    $script:RangerBehaviorRetryCount = if ($config.behavior.retryCount -gt 0) { [int]$config.behavior.retryCount } else { 0 }
    $validation = Test-RangerConfiguration -Config $config -PassThru
    if (-not $validation.IsValid) {
        throw ($validation.Errors -join [Environment]::NewLine)
    }

    $selectedCollectors = Resolve-RangerSelectedCollectors -Config $config
    $credentialMap = Resolve-RangerCredentialMap -Config $config -Overrides $CredentialOverrides
    $basePath = if ($ConfigPath) { Split-Path -Parent (Resolve-RangerPath -Path $ConfigPath) } else { (Get-Location).Path }
    $resolvedBaselineManifestPath = if (-not [string]::IsNullOrWhiteSpace($BaselineManifestPath)) { Resolve-RangerPath -Path $BaselineManifestPath -BasePath $basePath } else { $null }
    $packageRoot = New-RangerPackageRoot -Config $config -OutputPathOverride $OutputPath -BasePath $basePath
    $script:RangerLogPath = $null
    $script:RangerRetryDetails = New-Object System.Collections.ArrayList
    $script:RangerWinRmProbeCache = @{}
    $logPath = Initialize-RangerFileLog -PackageRoot $packageRoot
    $transcriptPath = Join-Path -Path $packageRoot -ChildPath 'ranger.transcript.log'
    $manifest = $null
    $manifestPath = $null
    $manifestValidation = $null
    $driftReport = $null
    $runStatusPath = $null
    $script:_rangerPrevVerbosePreference = $VerbosePreference
    $script:_rangerPrevDebugPreference = $DebugPreference
    $script:_rangerPrevInformationPreference = $InformationPreference
    $script:_rangerPrevProgressPreference = $ProgressPreference

    # Issue #163: never set $DebugPreference = 'Continue' — MSAL 4.x and the Az SDK emit
    # thousands of internal debug lines via Write-Debug, inflating ranger.log from ~2 KB to
    # 512 KB+ and burying actionable output. Ranger uses Write-RangerLog for all structured
    # logging; $DebugPreference is left at its caller-set value in all log levels.
    switch ($script:RangerLogLevel) {
        'debug' {
            $VerbosePreference     = 'Continue'
            $InformationPreference = 'Continue'
            $ProgressPreference    = 'Continue'
        }
        default {
            $VerbosePreference     = 'SilentlyContinue'
            $InformationPreference = 'SilentlyContinue'
            $ProgressPreference    = 'SilentlyContinue'
        }
    }
    $DebugPreference = 'SilentlyContinue'

    # Install a global Write-Warning proxy so warnings from ANY module (Az, WinRM, S2D, etc.) are
    # captured in the run log for the duration of this run, then restored in the finally block.
    $script:_rangerPrevWriteWarning = Get-Item function:\global:Write-Warning -ErrorAction SilentlyContinue
    function global:Write-Warning {
        param([AllowNull()][object]$Message)

        $messageText = ConvertTo-RangerLogMessage -InputObject $Message
        $currentLevel = Resolve-RangerLogLevel -Level $(if ($script:RangerLogLevel) { $script:RangerLogLevel } else { 'info' })
        if ((Get-RangerLogLevelRank -Level 'warn') -ge (Get-RangerLogLevelRank -Level $currentLevel) -and -not [string]::IsNullOrWhiteSpace($messageText) -and $script:RangerLogPath) {
            try {
                Add-Content -LiteralPath $script:RangerLogPath -Value "[$((Get-Date).ToString('s'))][WARN] $messageText" -Encoding UTF8 -ErrorAction Stop
            }
            catch {
            }
        }

        Microsoft.PowerShell.Utility\Write-Warning -Message $messageText
    }

    try {
        try {
            Start-Transcript -Path $transcriptPath -Force -ErrorAction Stop | Out-Null
        }
        catch {
            Write-RangerLog -Level warn -Message "Transcript start failed: $($_.Exception.Message)"
        }

        Write-RangerLog -Level info -Message "AzureLocalRanger run started — package: $(Split-Path -Leaf $packageRoot)"
        $manifest = New-RangerManifest -Config $config -SelectedCollectors $selectedCollectors
        $manifest.run.unattended = [bool]$Unattended
        $manifest.run.baselineManifestPath = $resolvedBaselineManifestPath
        $manifest.run.retryDetails = @()
        $manifestPath = Join-Path -Path $packageRoot -ChildPath 'manifest\audit-manifest.json'
        $evidenceRoot = Join-Path -Path $packageRoot -ChildPath 'evidence'

        # Issue #139 — WinRM preflight: probe all cluster targets before any collector runs.
        # Fails the run immediately when any configured target is unreachable.
        $preflightTargets = [System.Collections.Generic.List[string]]::new()
        if (-not [string]::IsNullOrWhiteSpace([string]$config.targets.cluster.fqdn) -and -not (Test-RangerPlaceholderValue -Value $config.targets.cluster.fqdn -FieldName 'targets.cluster.fqdn')) {
            $preflightTargets.Add([string]$config.targets.cluster.fqdn)
        }
        foreach ($node in @($config.targets.cluster.nodes)) {
            if (-not [string]::IsNullOrWhiteSpace([string]$node) -and -not (Test-RangerPlaceholderValue -Value $node -FieldName 'targets.cluster.node') -and $node -notin $preflightTargets) {
                $preflightTargets.Add([string]$node)
            }
        }

        if ($preflightTargets.Count -gt 0) {
            $retryCount = if ($config.behavior -and $config.behavior.retryCount -gt 0) { [int]$config.behavior.retryCount } else { 1 }
            $timeoutSec = if ($config.behavior -and $config.behavior.timeoutSeconds -gt 0) { [int]$config.behavior.timeoutSeconds } else { 0 }
            Write-RangerLog -Level info -Message "WinRM preflight probing $($preflightTargets.Count) target(s): $($preflightTargets -join ', ')"
            $remoteExecution = Resolve-RangerRemoteExecutionCredential -Targets $preflightTargets -ClusterCredential $credentialMap.cluster -DomainCredential $credentialMap.domain -RetryCount $retryCount -TimeoutSeconds $timeoutSec
            $manifest.run.remoteExecution = [ordered]@{
                selectedSource = $remoteExecution.SelectedSource
                userName       = $remoteExecution.UserName
                detail         = $remoteExecution.Detail
                targets        = @($preflightTargets)
                results        = @($remoteExecution.Results)
            }
            if ($remoteExecution.Credential -or $remoteExecution.SelectedSource -eq 'current-context') {
                $credentialMap.cluster = $remoteExecution.Credential
            }
            Write-RangerLog -Level info -Message $remoteExecution.Detail
            foreach ($rr in @($remoteExecution.Results)) {
                Write-RangerLog -Level info -Message "Remote authorization preflight: '$($rr.Target)' reached '$($rr.RemoteComputerName)' as '$($rr.RemoteIdentity)' via $($remoteExecution.SelectedSource) credential '$($remoteExecution.UserName)'"
            }
        }

        # Issue #30 — Build connectivity matrix after WinRM preflight so we know which
        # transport surfaces are reachable before any collector attempts a connection.
        # The matrix is stored in the manifest for observability and passed to each
        # collector execution so unreachable transports produce 'skipped' not 'failed'.
        $connectivityMatrix = $null
        if (Get-Command -Name 'Get-RangerConnectivityMatrix' -ErrorAction SilentlyContinue) {
            $ctxTimeout = if ($config.behavior -and $config.behavior.timeoutSeconds -gt 0) { [int]$config.behavior.timeoutSeconds } else { 10 }
            Write-RangerLog -Level info -Message "Connectivity matrix probe starting (timeout: $ctxTimeout s)"
            $connectivityMatrix = Get-RangerConnectivityMatrix -Config $config -TimeoutSeconds $ctxTimeout
            $manifest.run.connectivity = [ordered]@{
                posture      = $connectivityMatrix.posture
                probeTimeUtc = $connectivityMatrix.probeTimeUtc
                cluster      = $connectivityMatrix.cluster
                azure        = $connectivityMatrix.azure
                bmc          = $connectivityMatrix.bmc
                arc          = $connectivityMatrix.arc
            }
            Write-RangerLog -Level info -Message "Connectivity posture: $($connectivityMatrix.posture) — cluster=$($connectivityMatrix.cluster.reachable), azure=$($connectivityMatrix.azure.reachable), bmc=$($connectivityMatrix.bmc.reachable)"

            # Issue #26: probe Arc Run Command availability and update matrix.arc.available
            # so downstream code (and the manifest) can reflect the actual transport posture.
            if (Get-Command -Name 'Test-RangerArcTransportAvailable' -ErrorAction SilentlyContinue) {
                $arcAvailable = Test-RangerArcTransportAvailable -Config $config
                $connectivityMatrix.arc.available = $arcAvailable
                $manifest.run.connectivity.arc = [ordered]@{ available = $arcAvailable }
                Write-RangerLog -Level info -Message "Arc Run Command transport available: $arcAvailable"
            }

            # Surface a finding for any unreachable transport that has configured targets
            if (-not $connectivityMatrix.azure.reachable -and $connectivityMatrix.azure.enabled) {
                if (Get-Command -Name 'New-RangerConnectivityFinding' -ErrorAction SilentlyContinue) {
                    $manifest.findings += @(New-RangerConnectivityFinding -Surface 'azure' -Detail 'management.azure.com:443 unreachable')
                }
            }
            if (-not $connectivityMatrix.bmc.reachable -and @($connectivityMatrix.bmc.endpoints).Count -gt 0) {
                if (Get-Command -Name 'New-RangerConnectivityFinding' -ErrorAction SilentlyContinue) {
                    $bmcDetail = ($connectivityMatrix.bmc.endpoints | ForEach-Object { "$($_.host): unreachable" }) -join '; '
                    $manifest.findings += @(New-RangerConnectivityFinding -Surface 'bmc' -Detail $bmcDetail)
                }
            }
        }

        # Issue #76: initialise Spectre.Console progress display — degrades gracefully when
        # PwshSpectreConsole is absent, in CI, or in Unattended / non-interactive mode.
        $progressCtx = $null
        $showProgress = [bool]$config.output.showProgress
        if ($showProgress -and -not $Unattended -and (Get-Command -Name 'New-RangerProgressContext' -ErrorAction SilentlyContinue)) {
            $progressCtx = New-RangerProgressContext -Collectors $selectedCollectors
        }

        foreach ($collector in $selectedCollectors) {
            Write-RangerLog -Level info -Message "Collector '$($collector.Id)' starting"
            if ($progressCtx -and (Get-Command -Name 'Update-RangerProgressCollectorStart' -ErrorAction SilentlyContinue)) {
                Update-RangerProgressCollectorStart -Context $progressCtx -CollectorId $collector.Id
            }
            $collectorResult = Invoke-RangerCollectorExecution -Definition $collector -Config $config -CredentialMap $credentialMap -PackageRoot $packageRoot -ConnectivityMatrix $connectivityMatrix
            Write-RangerLog -Level info -Message "Collector '$($collector.Id)' completed with status '$($collectorResult.Status)'"
            if ($progressCtx -and (Get-Command -Name 'Update-RangerProgressCollectorDone' -ErrorAction SilentlyContinue)) {
                Update-RangerProgressCollectorDone -Context $progressCtx -CollectorId $collector.Id -Status $collectorResult.Status
            }
            Add-RangerCollectorToManifest -Manifest ([ref]$manifest) -CollectorResult $collectorResult -EvidenceRoot $evidenceRoot -KeepRawEvidence ([bool]$config.output.keepRawEvidence)
        }

        if ($progressCtx -and (Get-Command -Name 'Complete-RangerProgressDisplay' -ErrorAction SilentlyContinue)) {
            Complete-RangerProgressDisplay -Context $progressCtx
        }

        $manifest.run.retryDetails = @($script:RangerRetryDetails)

        $manifestValidation = Test-RangerManifestSchema -Manifest $manifest -SelectedCollectors $selectedCollectors
        $manifest.run.schemaValidation = [ordered]@{
            isValid  = $manifestValidation.IsValid
            errors   = @($manifestValidation.Errors)
            warnings = @($manifestValidation.Warnings)
        }

        if ($manifestValidation.Warnings.Count -gt 0) {
            $manifest.findings += @(
                New-RangerFinding -Severity informational -Title 'Manifest schema warnings were recorded' -Description 'The generated manifest passed core validation but recorded schema warnings that should be reviewed before handoff.' -CurrentState ($manifestValidation.Warnings -join '; ') -Recommendation 'Review duplicate artifact paths or incomplete metadata before packaging the deliverable.'
            )
        }

        if (-not $manifestValidation.IsValid) {
            $manifest.findings += @(
                New-RangerFinding -Severity warning -Title 'Manifest schema validation failed' -Description 'The generated manifest did not satisfy the minimum schema contract for Ranger outputs.' -CurrentState ($manifestValidation.Errors -join '; ') -Recommendation 'Correct the collector payload or manifest contract before treating this package as a handoff-ready deliverable.'
            )
        }

        if ($resolvedBaselineManifestPath) {
            if (-not (Test-Path -Path $resolvedBaselineManifestPath)) {
                throw "Baseline manifest file not found: $resolvedBaselineManifestPath"
            }

            $baselineManifest = Get-Content -Path $resolvedBaselineManifestPath -Raw | ConvertFrom-Json -AsHashtable -Depth 100
            $driftReport = New-RangerDriftReport -CurrentManifest $manifest -BaselineManifest (ConvertTo-RangerHashtable -InputObject $baselineManifest) -BaselineManifestPath $resolvedBaselineManifestPath
            $manifest.run.drift = [ordered]@{
                status        = $driftReport.status
                summary       = $driftReport.summary
                skippedReason = $driftReport.skippedReason
            }

            $driftArtifactPath = Write-RangerJsonArtifact -PackageRoot $packageRoot -RelativePath 'manifest\drift-report.json' -Content $driftReport
            $manifest.artifacts += @(
                New-RangerArtifactRecord -Type 'drift-report' -RelativePath ([System.IO.Path]::GetRelativePath($packageRoot, $driftArtifactPath)) -Status $(if ($driftReport.status -eq 'generated') { 'generated' } else { 'skipped' }) -Audience 'all' -Reason $driftReport.skippedReason
            )

            if ($driftReport.status -eq 'generated') {
                foreach ($change in @($driftReport.changes)) {
                    $manifest.findings += @(
                        New-RangerFinding -Severity informational -Title "Detected manifest drift: $($change.changeType)" -Description "Detected a $($change.changeType) change at $($change.path)." -CurrentState $(if ($null -ne $change.baselineValue -and $null -ne $change.currentValue) { "baseline=$($change.baselineValue | ConvertTo-Json -Depth 5 -Compress); current=$($change.currentValue | ConvertTo-Json -Depth 5 -Compress)" } elseif ($null -ne $change.currentValue) { "current=$($change.currentValue | ConvertTo-Json -Depth 5 -Compress)" } else { "baseline=$($change.baselineValue | ConvertTo-Json -Depth 5 -Compress)" }) -Recommendation 'Review whether this environment drift was expected and update the baseline manifest after the change is approved.'
                    )
                }
            }
            else {
                $manifest.findings += @(
                    New-RangerFinding -Severity informational -Title 'Baseline comparison was skipped' -Description 'Ranger received a baseline manifest path but did not generate a drift comparison.' -CurrentState $driftReport.skippedReason -Recommendation 'Confirm the baseline manifest schema version matches the current run before relying on drift analysis.'
                )
            }
        }

        Save-RangerManifest -Manifest $manifest -Path $manifestPath
        $manifest.artifacts += @(New-RangerArtifactRecord -Type 'manifest-json' -RelativePath ([System.IO.Path]::GetRelativePath($packageRoot, $manifestPath)) -Status generated -Audience 'all')

        if (-not $manifestValidation.IsValid -and [bool]$config.behavior.failOnSchemaViolation) {
            Save-RangerManifest -Manifest $manifest -Path $manifestPath
            throw ($manifestValidation.Errors -join [Environment]::NewLine)
        }

        if (-not $NoRender -and [bool]$config.behavior.continueToRendering) {
            $renderResult = Invoke-RangerOutputGeneration -Manifest $manifest -PackageRoot $packageRoot -Formats @($config.output.formats) -Mode $config.output.mode
            if ($renderResult.Artifacts) {
                $manifest.artifacts += @($renderResult.Artifacts)
            }
            Save-RangerManifest -Manifest $manifest -Path $manifestPath
        }

        $packageIndexPath = New-RangerPackageIndex -Manifest $manifest -ManifestPath $manifestPath -PackageRoot $packageRoot
        $manifest.artifacts += @(New-RangerArtifactRecord -Type 'package-index' -RelativePath ([System.IO.Path]::GetRelativePath($packageRoot, $packageIndexPath)) -Status generated -Audience 'all')

        if ($logPath -and (Test-Path -LiteralPath $logPath)) {
            Write-RangerLog -Level info -Message "Run complete — package: $(Split-Path -Leaf $packageRoot)"
            $manifest.artifacts += @(New-RangerArtifactRecord -Type 'run-log' -RelativePath ([System.IO.Path]::GetRelativePath($packageRoot, $logPath)) -Status generated -Audience 'all')
        }

        $runStatus = New-RangerRunStatus -Unattended ([bool]$Unattended) -Status 'success' -Manifest $manifest -ManifestPath $manifestPath -LogPath $logPath -DriftStatus $(if ($driftReport) { $driftReport.status } else { 'not-requested' })
        $runStatusPath = Write-RangerJsonArtifact -PackageRoot $packageRoot -RelativePath 'run-status.json' -Content $runStatus
        $manifest.artifacts += @(New-RangerArtifactRecord -Type 'run-status' -RelativePath ([System.IO.Path]::GetRelativePath($packageRoot, $runStatusPath)) -Status generated -Audience 'all')

        Save-RangerManifest -Manifest $manifest -Path $manifestPath

        if ($Unattended -and @($manifest.collectors.Values | Where-Object { $_.status -eq 'failed' }).Count -gt 0) {
            throw 'Unattended run completed with one or more failed collectors. See run-status.json and ranger.log for details.'
        }

        [ordered]@{
            Config       = $config
            Manifest     = $manifest
            ManifestPath = $manifestPath
            PackageRoot  = $packageRoot
            LogPath      = $logPath
            Validation   = $validation
            ManifestSchema = $manifestValidation
        }
    }
    catch {
        if ($packageRoot) {
            $failureDriftStatus = if ($driftReport) { $driftReport.status } else { 'not-requested' }
            $failureRunStatus = New-RangerRunStatus -Unattended ([bool]$Unattended) -Status 'failed' -ErrorMessage $_.Exception.Message -Manifest $manifest -ManifestPath $manifestPath -LogPath $logPath -DriftStatus $failureDriftStatus
            $runStatusPath = Write-RangerJsonArtifact -PackageRoot $packageRoot -RelativePath 'run-status.json' -Content $failureRunStatus

            if ($manifest) {
                if (-not @($manifest.artifacts | Where-Object { $_.type -eq 'run-status' -and $_.relativePath -eq ([System.IO.Path]::GetRelativePath($packageRoot, $runStatusPath)) }).Count) {
                    $manifest.artifacts += @(New-RangerArtifactRecord -Type 'run-status' -RelativePath ([System.IO.Path]::GetRelativePath($packageRoot, $runStatusPath)) -Status generated -Audience 'all')
                }

                if ($manifestPath) {
                    Save-RangerManifest -Manifest $manifest -Path $manifestPath
                }
            }
        }

        throw
    }
    finally {
        try {
            Stop-Transcript | Out-Null
        }
        catch {
        }

        if ($logPath -and (Test-Path -LiteralPath $logPath) -and (Test-Path -LiteralPath $transcriptPath)) {
            try {
                Add-Content -LiteralPath $logPath -Value @('', '# Host transcript', '') -Encoding UTF8 -ErrorAction Stop
                Get-Content -LiteralPath $transcriptPath -ErrorAction Stop | Add-Content -LiteralPath $logPath -Encoding UTF8 -ErrorAction Stop
            }
            catch {
            }
        }

        # Restore whatever Write-Warning existed before the run (usually the built-in)
        if ($script:_rangerPrevWriteWarning) {
            Set-Item function:\global:Write-Warning -Value $script:_rangerPrevWriteWarning.ScriptBlock
        } else {
            Remove-Item function:\global:Write-Warning -ErrorAction SilentlyContinue
        }
        $VerbosePreference = $script:_rangerPrevVerbosePreference
        $DebugPreference = $script:_rangerPrevDebugPreference
        $InformationPreference = $script:_rangerPrevInformationPreference
        $ProgressPreference = $script:_rangerPrevProgressPreference
        $script:RangerLogPath = $null
        $script:RangerRetryDetails = $null
        $script:RangerWinRmProbeCache = $null
        $script:RangerBehaviorRetryCount = $null
    }
}