Public/Invoke-IdlePlanObject.ps1

function Invoke-IdlePlanObject {
    <#
    .SYNOPSIS
    Executes an IdLE plan object and returns a deterministic execution result.

    .DESCRIPTION
    Executes steps in order, emits structured events, and returns a stable execution result.

    Provider resolution:
    - If -Providers is supplied, it is used for execution.
    - If -Providers is not supplied (null), Plan.Providers is used if available.
    - If neither is present, execution fails early with a clear error message.

    Security:
    - ScriptBlocks are rejected in plan and providers.
    - The returned execution result is an output boundary: Providers are redacted.

    .PARAMETER Plan
    Plan object created by New-IdlePlanObject.

    .PARAMETER Providers
    Provider registry/collection passed through to execution.
    If omitted and Plan.Providers exists, Plan.Providers will be used.
    If supplied, overrides Plan.Providers.

    .PARAMETER EventSink
    Optional external event sink object. Must provide a WriteEvent(event) method.

    .PARAMETER ExecutionOptions
    Optional host-owned execution options. Supports retry profile configuration.
    Must be a hashtable with optional keys: RetryProfiles, DefaultRetryProfile.

    .OUTPUTS
    PSCustomObject (PSTypeName: IdLE.ExecutionResult)

    .EXAMPLE
    $result = Invoke-IdlePlanObject -Plan $plan -Providers $providers

    Executes a plan with the specified provider registry and returns an execution result.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object] $Plan,

        [Parameter()]
        [AllowNull()]
        [object] $Providers,

        [Parameter()]
        [AllowNull()]
        [object] $EventSink,

        [Parameter()]
        [AllowNull()]
        [hashtable] $ExecutionOptions
    )

    $planPropNames = @($Plan.PSObject.Properties.Name)

    $request = if ($planPropNames -contains 'Request') { $Plan.Request } else { $null }
    $requestPropNames = if ($null -ne $request) { @($request.PSObject.Properties.Name) } else { @() }

    $corr = if ($null -ne $request -and $requestPropNames -contains 'CorrelationId') {
        $request.CorrelationId
    }
    else {
        if ($planPropNames -contains 'CorrelationId') { $Plan.CorrelationId } else { $null }
    }

    $actor = if ($null -ne $request -and $requestPropNames -contains 'Actor') {
        $request.Actor
    }
    else {
        if ($planPropNames -contains 'Actor') { $Plan.Actor } else { $null }
    }

    $events = [System.Collections.Generic.List[object]]::new()

    # Host may pass an external sink. If none is provided, we still buffer events internally.
    $engineEventSink = New-IdleEventSink -CorrelationId $corr -Actor $actor -ExternalEventSink $EventSink -EventBuffer $events

    # Enforce data-only boundary: reject ScriptBlocks in untrusted inputs.
    # Special-case: for auth session acquisition options, throw a contextualized error message.
    $planSteps = if ($planPropNames -contains 'Steps') { $Plan.Steps } else { $null }
    if ($null -ne $planSteps -and ($planSteps -is [System.Collections.IEnumerable]) -and ($planSteps -isnot [string])) {
        $i = 0
        foreach ($step in $planSteps) {
            $stepType = [string](Get-IdlePropertyValue -Object $step -Name 'Type')
            if ($stepType -eq 'IdLE.Step.AcquireAuthSession') {
                $with = Get-IdlePropertyValue -Object $step -Name 'With'
                $options = $null
                if ($null -ne $with) {
                    if ($with -is [System.Collections.IDictionary]) {
                        if ($with.Contains('Options')) { $options = $with['Options'] }
                    }
                    else {
                        if ($with.PSObject.Properties.Name -contains 'Options') { $options = $with.Options }
                    }
                }

                Assert-IdleNoScriptBlockInAuthSessionOptions -InputObject $options -Path "Plan.Steps[$i].With.Options"
            }

            $i++
        }
    }

    Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan'

    # Resolve effective providers: explicit -Providers parameter takes precedence, otherwise use Plan.Providers.
    # This allows the common workflow: build plan with providers once, execute without re-supplying them.
    $effectiveProviders = $Providers
    if ($null -eq $effectiveProviders) {
        if ($planPropNames -contains 'Providers') {
            $planProviders = $Plan.Providers
            # Accept both IDictionary (hashtables) and PSCustomObject-shaped provider registries
            if ($null -ne $planProviders) {
                $isValidProvider = ($planProviders -is [System.Collections.IDictionary]) -or 
                ($planProviders.PSObject -and $planProviders.PSObject.Properties)
                if ($isValidProvider) {
                    $effectiveProviders = $planProviders
                }
            }
        }
    }

    # Early validation: fail with a clear message if no providers are available.
    if ($null -eq $effectiveProviders) {
        throw [System.InvalidOperationException]::new(
            'Providers are required. Provide -Providers to Invoke-IdlePlan or build the plan with Providers.'
        )
    }

    Assert-IdleNoScriptBlock -InputObject $effectiveProviders -Path 'Providers'

    # Validate ExecutionOptions
    Assert-IdleExecutionOptions -ExecutionOptions $ExecutionOptions

    # StepRegistry is constructed via helper to ensure built-in steps and host-provided steps can co-exist.
    $stepRegistry = Get-IdleStepRegistry -Providers $effectiveProviders

    $context = [pscustomobject]@{
        PSTypeName = 'IdLE.ExecutionContext'
        Plan       = $Plan
        Providers  = $effectiveProviders
        EventSink  = $engineEventSink
    }

    # Expose common run metadata on the execution context so providers can enrich session acquisition requests
    # without having to parse the plan structure themselves.
    $null = $context | Add-Member -MemberType NoteProperty -Name CorrelationId -Value $corr -Force
    $null = $context | Add-Member -MemberType NoteProperty -Name Actor -Value $actor -Force

    # Session acquisition boundary:
    # - Providers MUST NOT implement their own authentication flows.
    # - The host supplies an AuthSessionBroker in Providers.AuthSessionBroker.
    # - Options must be data-only (no ScriptBlocks).
    $null = $context | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $Name,

            [Parameter()]
            [AllowNull()]
            [hashtable] $Options
        )

        $providers = $this.Providers
        $broker = $null

        if ($providers -is [System.Collections.IDictionary]) {
            if ($providers.Contains('AuthSessionBroker')) {
                $broker = $providers['AuthSessionBroker']
            }
        }
        else {
            if ($null -ne $providers -and $providers.PSObject.Properties.Name -contains 'AuthSessionBroker') {
                $broker = $providers.AuthSessionBroker
            }
        }

        if ($null -eq $broker) {
            throw [System.InvalidOperationException]::new(
                'No AuthSessionBroker configured. Provide Providers.AuthSessionBroker to acquire auth sessions during execution.'
            )
        }

        if ($broker.PSObject.Methods.Name -notcontains 'AcquireAuthSession') {
            throw [System.InvalidOperationException]::new(
                'AuthSessionBroker must provide an AcquireAuthSession(Name, Options) method.'
            )
        }

        $normalizedOptions = if ($null -eq $Options) { @{} } else { $Options }
        Assert-IdleNoScriptBlockInAuthSessionOptions -InputObject $normalizedOptions -Path 'AuthSessionOptions'

        # Copy options to avoid mutating caller-owned hashtables.
        $optionsCopy = Copy-IdleDataObject -Value $normalizedOptions

        if ($null -ne $this.CorrelationId) { $optionsCopy['CorrelationId'] = $this.CorrelationId }
        if ($null -ne $this.Actor) { $optionsCopy['Actor'] = $this.Actor }

        return $broker.AcquireAuthSession($Name, $optionsCopy)
    } -Force

    # Fail-fast security validation: Check if AuthSessionBroker is required but missing.
    # AcquireAuthSession steps require an AuthSessionBroker to be present in Providers.
    # Skip NotApplicable steps, as they won't be executed and don't require the broker.
    $requiresAuthBroker = $false
    $steps = if ($planPropNames -contains 'Steps') { $Plan.Steps } else { @() }
    foreach ($step in $steps) {
        if ($null -eq $step) { continue }

        $stepType = $null
        $stepStatus = $null
        if ($step -is [System.Collections.IDictionary]) {
            if ($step.Contains('Type')) {
                $stepType = $step['Type']
            }
            if ($step.Contains('Status')) {
                $stepStatus = $step['Status']
            }
        }
        else {
            $stepPropNames = @($step.PSObject.Properties.Name)
            $stepType = if ($stepPropNames -contains 'Type') { $step.Type } else { $null }
            $stepStatus = if ($stepPropNames -contains 'Status') { $step.Status } else { $null }
        }

        if ($stepType -eq 'IdLE.Step.AcquireAuthSession' -and $stepStatus -ne 'NotApplicable') {
            $requiresAuthBroker = $true
            break
        }
    }

    if ($requiresAuthBroker) {
        $broker = $null
        if ($effectiveProviders -is [System.Collections.IDictionary]) {
            if ($effectiveProviders.Contains('AuthSessionBroker')) {
                $broker = $effectiveProviders['AuthSessionBroker']
            }
        }
        else {
            if ($null -ne $effectiveProviders -and $effectiveProviders.PSObject.Properties.Name -contains 'AuthSessionBroker') {
                $broker = $effectiveProviders.AuthSessionBroker
            }
        }

        if ($null -eq $broker) {
            throw [System.InvalidOperationException]::new(
                'AuthSessionBroker is required but not configured. One or more steps require auth session acquisition. Provide Providers.AuthSessionBroker to proceed.'
            )
        }
    }

    $context.EventSink.WriteEvent(
        'RunStarted',
        'Plan execution started.',
        $null,
        @{
            CorrelationId = $corr
            Actor         = $actor
            StepCount     = @($Plan.Steps).Count
        }
    )

    $failed = $false
    $blocked = $false
    $stepResults = @()

    # Precondition evaluation context: includes Plan and Request for condition DSL path resolution.
    $preconditionContext = @{
        Plan    = $Plan
        Request = $request
    }

    $i = 0
    foreach ($step in $Plan.Steps) {

        if ($null -eq $step) {
            continue
        }

        $stepName = [string](Get-IdlePropertyValue -Object $step -Name 'Name')
        if ($null -eq $stepName) { $stepName = '' }

        $stepType = Get-IdlePropertyValue -Object $step -Name 'Type'
        $stepWith = Get-IdlePropertyValue -Object $step -Name 'With'
        $stepStatus = [string](Get-IdlePropertyValue -Object $step -Name 'Status')
        if ($null -eq $stepStatus) { $stepStatus = '' }

        # Conditions are evaluated during planning and represented as Step.Status.
        if ($stepStatus -eq 'NotApplicable') {

            $stepResults += [pscustomobject]@{
                PSTypeName = 'IdLE.StepResult'
                Name       = $stepName
                Type       = $stepType
                Status     = 'NotApplicable'
                Attempts   = 1
            }

            $context.EventSink.WriteEvent(
                'StepNotApplicable',
                "Step '$stepName' not applicable (condition not met).",
                $stepName,
                @{
                    StepType = $stepType
                    Index    = $i
                }
            )

            $i++
            continue
        }

        # Runtime Precondition: evaluated immediately before step execution (online, not planning-time).
        # Blocked = policy/precondition gate (does not trigger OnFailureSteps). Stops execution.
        # Fail = treated as a technical failure (triggers OnFailureSteps). Stops execution.
        # Continue = emits events but skips the step and continues to the next step.
        # Non-IDictionary precondition nodes are treated as precondition failures (fail closed).
        $stepPrecondition = Get-IdlePropertyValue -Object $step -Name 'Precondition'

        # Set Request.Context.Current alias for step-relative path resolution in preconditions.
        # Resolved from Step.With.Provider + Step.With.AuthSessionName (or 'Default').
        # Scoped to the precondition evaluation; cleaned up immediately after.
        $currentContextSet = $false
        if ($null -ne $stepPrecondition -and $null -ne $request -and $null -ne $request.Context -and $request.Context -is [System.Collections.IDictionary]) {
            # If the caller has already provided a Context['Current'], do not overwrite it.
            $currentAlreadyPresent = $request.Context.Contains('Current')

            if (-not $currentAlreadyPresent) {
                $currentProviderAlias = $null
                $currentAuthKey = 'Default'
                if ($null -ne $stepWith) {
                    if ($stepWith -is [System.Collections.IDictionary]) {
                        if ($stepWith.Contains('Provider') -and -not [string]::IsNullOrWhiteSpace([string]$stepWith['Provider'])) {
                            $currentProviderAlias = [string]$stepWith['Provider']
                        }
                        if ($stepWith.Contains('AuthSessionName') -and -not [string]::IsNullOrWhiteSpace([string]$stepWith['AuthSessionName'])) {
                            $currentAuthKey = [string]$stepWith['AuthSessionName']
                        }
                    }
                    elseif ($stepWith.PSObject.Properties.Name -contains 'Provider') {
                        $pVal = $stepWith.Provider
                        if (-not [string]::IsNullOrWhiteSpace([string]$pVal)) { $currentProviderAlias = [string]$pVal }
                        $aVal = if ($stepWith.PSObject.Properties.Name -contains 'AuthSessionName') { $stepWith.AuthSessionName } else { $null }
                        if (-not [string]::IsNullOrWhiteSpace([string]$aVal)) { $currentAuthKey = [string]$aVal }
                    }
                }

                $currentContextValue = $null
                if (-not [string]::IsNullOrWhiteSpace($currentProviderAlias)) {
                    $providersNode = if ($request.Context.Contains('Providers')) { $request.Context['Providers'] } else { $null }
                    if ($null -ne $providersNode -and $providersNode -is [System.Collections.IDictionary] -and $providersNode.Contains($currentProviderAlias)) {
                        $providerNode = $providersNode[$currentProviderAlias]
                        if ($null -ne $providerNode -and $providerNode -is [System.Collections.IDictionary] -and $providerNode.Contains($currentAuthKey)) {
                            $currentContextValue = $providerNode[$currentAuthKey]
                        }
                    }
                }

                $request.Context['Current'] = $currentContextValue
                $currentContextSet = $true
            }
        }

        if ($null -ne $stepPrecondition) {
            $preconditionPassed = $true
            if ($stepPrecondition -isnot [System.Collections.IDictionary]) {
                # Fail closed: a malformed or unexpected node type is treated as a failed precondition.
                $preconditionPassed = $false
            }
            else {
                # Validate that all non-Exists paths exist at execution time.
                # Exists operator paths are excluded because Exists semantics intentionally allow missing paths.
                Assert-IdleConditionPathsResolvable -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext -StepName $stepName -Source 'Precondition' -ExcludeExistsOperatorPaths
                if (-not (Test-IdleCondition -Condition ([hashtable]$stepPrecondition) -Context $preconditionContext)) {
                    $preconditionPassed = $false
                }
            }

            if (-not $preconditionPassed) {
                $onPreconditionFalse = [string](Get-IdlePropertyValue -Object $step -Name 'OnPreconditionFalse')
                if ([string]::IsNullOrWhiteSpace($onPreconditionFalse)) { $onPreconditionFalse = 'Blocked' }

                # Always emit StepPreconditionFailed for engine observability.
                $context.EventSink.WriteEvent(
                    'StepPreconditionFailed',
                    "Step '$stepName' precondition check failed.",
                    $stepName,
                    @{
                        StepType            = $stepType
                        Index               = $i
                        OnPreconditionFalse = $onPreconditionFalse
                    }
                )

                # Emit the caller-configured PreconditionEvent if present.
                $pcEvt = Get-IdlePropertyValue -Object $step -Name 'PreconditionEvent'
                if ($null -ne $pcEvt) {
                    $pcEvtType = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Type')
                    $pcEvtMsg = [string](Get-IdlePropertyValue -Object $pcEvt -Name 'Message')
                    $pcEvtData = Get-IdlePropertyValue -Object $pcEvt -Name 'Data'
                    # PreconditionEvent.Data is validated as a hashtable at planning time and
                    # stored via Copy-IdleDataObject, so it will be a hashtable (IDictionary) here.
                    $pcEvtDataHt = if ($pcEvtData -is [System.Collections.IDictionary]) { [hashtable]$pcEvtData } else { $null }
                    $context.EventSink.WriteEvent($pcEvtType, $pcEvtMsg, $stepName, $pcEvtDataHt)
                }

                if ($onPreconditionFalse -eq 'Fail') {
                    $failed = $true
                    $stepResults += [pscustomobject]@{
                        PSTypeName = 'IdLE.StepResult'
                        Name       = $stepName
                        Type       = $stepType
                        Status     = 'Failed'
                        Error      = 'Precondition check failed.'
                        Attempts   = 0
                    }
                    $context.EventSink.WriteEvent(
                        'StepFailed',
                        "Step '$stepName' failed (precondition check failed).",
                        $stepName,
                        @{
                            StepType = $stepType
                            Index    = $i
                            Error    = 'Precondition check failed.'
                        }
                    )
                }
                elseif ($onPreconditionFalse -eq 'Continue') {
                    # Emit events and skip the step; continue to subsequent steps.
                    $stepResults += [pscustomobject]@{
                        PSTypeName = 'IdLE.StepResult'
                        Name       = $stepName
                        Type       = $stepType
                        Status     = 'PreconditionSkipped'
                        Attempts   = 0
                    }
                    $i++
                    # Clean up the Current alias before continuing to the next step.
                    if ($currentContextSet -and $null -ne $request -and $null -ne $request.Context -and $request.Context -is [System.Collections.IDictionary]) {
                        $null = $request.Context.Remove('Current')
                    }
                    continue
                }
                else {
                    # Default: Blocked. Does not trigger OnFailureSteps.
                    $blocked = $true
                    $stepResults += [pscustomobject]@{
                        PSTypeName = 'IdLE.StepResult'
                        Name       = $stepName
                        Type       = $stepType
                        Status     = 'Blocked'
                        Attempts   = 0
                    }
                    $context.EventSink.WriteEvent(
                        'StepBlocked',
                        "Step '$stepName' blocked (precondition check failed).",
                        $stepName,
                        @{
                            StepType = $stepType
                            Index    = $i
                        }
                    )
                }

                # Clean up the Current alias before exiting the step loop.
                if ($currentContextSet -and $null -ne $request -and $null -ne $request.Context -and $request.Context -is [System.Collections.IDictionary]) {
                    $null = $request.Context.Remove('Current')
                }

                break
            }
        }

        # Stop processing if a precondition failure was handled above.
        if ($failed -or $blocked) { break }

        # Clean up the Current alias after precondition evaluation.
        if ($currentContextSet -and $null -ne $request -and $null -ne $request.Context -and $request.Context -is [System.Collections.IDictionary]) {
            $null = $request.Context.Remove('Current')
        }

        $context.EventSink.WriteEvent(
            'StepStarted',
            "Step '$stepName' started.",
            $stepName,
            @{
                StepType = $stepType
                Index    = $i
            }
        )

        try {
            $impl = Resolve-IdleStepHandler -StepType ([string]$stepType) -StepRegistry $stepRegistry

            $supportedParams = Get-IdleCommandParameterNames -Handler $impl

            $invokeParams = @{}

            # Backwards compatibility: pass -Context only when the handler supports it.
            if ($supportedParams.Contains('Context')) {
                $invokeParams.Context = $context
            }

            if ($null -ne $stepWith -and $supportedParams.Contains('With')) {
                $invokeParams.With = $stepWith
            }

            if ($supportedParams.Contains('Step')) {
                $invokeParams.Step = $step
            }

            # Safe-by-default transient retries:
            # - Only retries if the thrown exception is explicitly marked transient.
            # - Emits 'StepRetrying' events and uses deterministic jitter/backoff.
            # - Retry parameters resolved from ExecutionOptions if provided.
            $retrySeed = "$corr|$stepType|$stepName|$i"
            $retryParams = Resolve-IdleStepRetryParameters -Step $step -ExecutionOptions $ExecutionOptions
            $retry = Invoke-IdleWithRetry `
                -Operation { & $impl @invokeParams } `
                -MaxAttempts $retryParams.MaxAttempts `
                -InitialDelayMilliseconds $retryParams.InitialDelayMilliseconds `
                -BackoffFactor $retryParams.BackoffFactor `
                -MaxDelayMilliseconds $retryParams.MaxDelayMilliseconds `
                -JitterRatio $retryParams.JitterRatio `
                -EventSink $context.EventSink `
                -StepName $stepName `
                -OperationName 'StepExecution' `
                -DeterministicSeed $retrySeed

            $result = $retry.Value
            $attempts = [int]$retry.Attempts

            if ($null -eq $result) {
                $result = [pscustomobject]@{
                    PSTypeName = 'IdLE.StepResult'
                    Name       = $stepName
                    Type       = $stepType
                    Status     = 'Completed'
                    Attempts   = $attempts
                }
            }
            else {
                # Normalize result to include Attempts for observability (non-breaking).
                if ($result.PSObject.Properties.Name -notcontains 'Attempts') {
                    $null = $result | Add-Member -MemberType NoteProperty -Name Attempts -Value $attempts -Force
                }
            }

            $stepResults += $result

            if ($result.Status -eq 'Failed') {
                $failed = $true

                $context.EventSink.WriteEvent(
                    'StepFailed',
                    "Step '$stepName' failed.",
                    $stepName,
                    @{
                        StepType = $stepType
                        Index    = $i
                        Error    = $result.Error
                    }
                )

                break
            }

            $context.EventSink.WriteEvent(
                'StepCompleted',
                "Step '$stepName' completed.",
                $stepName,
                @{
                    StepType = $stepType
                    Index    = $i
                }
            )
        }
        catch {
            $failed = $true
            $err = $_

            $stepResults += [pscustomobject]@{
                PSTypeName = 'IdLE.StepResult'
                Name       = $stepName
                Type       = $stepType
                Status     = 'Failed'
                Error      = $err.Exception.Message
                Attempts   = 1
            }

            $context.EventSink.WriteEvent(
                'StepFailed',
                "Step '$stepName' failed.",
                $stepName,
                @{
                    StepType = $stepType
                    Index    = $i
                    Error    = $err.Exception.Message
                }
            )

            break
        }

        $i++
    }

    $runStatus = if ($blocked) { 'Blocked' } elseif ($failed) { 'Failed' } else { 'Completed' }

    # Public result contract: the OnFailure section is always present.
    $onFailure = [pscustomobject]@{
        PSTypeName = 'IdLE.OnFailureExecutionResult'
        Status     = 'NotRun'
        Steps      = [object[]]@()
    }

    $planOnFailureSteps = @()
    if ($planPropNames -contains 'OnFailureSteps') {
        # Treat nulls as empty deterministically.
        $planOnFailureSteps = @($Plan.OnFailureSteps) | Where-Object { $null -ne $_ }
    }

    # OnFailureSteps run only for genuine failures, NOT for Blocked outcomes (policy gates).
    if ($failed -and -not $blocked -and @($planOnFailureSteps).Count -gt 0) {
        $context.EventSink.WriteEvent(
            'OnFailureStarted',
            'Executing OnFailureSteps (best effort).',
            $null,
            @{
                OnFailureStepCount = @($planOnFailureSteps).Count
            }
        )

        $onFailureHadFailures = $false
        $onFailureStepResults = @()

        $j = 0
        foreach ($ofStep in @($planOnFailureSteps)) {

            if ($null -eq $ofStep) {
                $j++
                continue
            }

            $ofPropNames = @($ofStep.PSObject.Properties.Name)
            $ofName = if ($ofPropNames -contains 'Name') { [string]$ofStep.Name } else { '' }
            $ofType = if ($ofPropNames -contains 'Type') { $ofStep.Type } else { $null }
            $ofWith = if ($ofPropNames -contains 'With') { $ofStep.With } else { $null }
            $ofStatus = if ($ofPropNames -contains 'Status') { [string]$ofStep.Status } else { '' }

            # Conditions for OnFailure steps are evaluated during planning as well.
            if ($ofStatus -eq 'NotApplicable') {

                $onFailureStepResults += [pscustomobject]@{
                    PSTypeName = 'IdLE.StepResult'
                    Name       = $ofName
                    Type       = $ofType
                    Status     = 'NotApplicable'
                    Attempts   = 1
                }

                $context.EventSink.WriteEvent(
                    'OnFailureStepNotApplicable',
                    "OnFailure step '$ofName' not applicable (condition not met).",
                    $ofName,
                    @{
                        StepType = $ofType
                        Index    = $j
                    }
                )

                $j++
                continue
            }

            # Runtime Precondition for OnFailure steps: evaluated immediately before execution.
            # OnFailure runs best-effort, so precondition failures skip the step but do not halt
            # remaining OnFailure steps. Non-IDictionary nodes are treated as failures (fail closed).
            $ofPrecondition = Get-IdlePropertyValue -Object $ofStep -Name 'Precondition'
            if ($null -ne $ofPrecondition) {
                $ofPreconditionPassed = $true
                if ($ofPrecondition -isnot [System.Collections.IDictionary]) {
                    $ofPreconditionPassed = $false
                }
                else {
                    # Validate that all non-Exists paths exist at execution time.
                    Assert-IdleConditionPathsResolvable -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext -StepName $ofName -Source 'Precondition' -ExcludeExistsOperatorPaths
                    if (-not (Test-IdleCondition -Condition ([hashtable]$ofPrecondition) -Context $preconditionContext)) {
                        $ofPreconditionPassed = $false
                    }
                }

                if (-not $ofPreconditionPassed) {
                    $ofOnPreconditionFalse = [string](Get-IdlePropertyValue -Object $ofStep -Name 'OnPreconditionFalse')
                    if ([string]::IsNullOrWhiteSpace($ofOnPreconditionFalse)) { $ofOnPreconditionFalse = 'Blocked' }

                    # Always emit StepPreconditionFailed for engine observability.
                    $context.EventSink.WriteEvent(
                        'StepPreconditionFailed',
                        "OnFailure step '$ofName' precondition check failed.",
                        $ofName,
                        @{
                            StepType            = $ofType
                            Index               = $j
                            OnPreconditionFalse = $ofOnPreconditionFalse
                        }
                    )

                    # Emit the caller-configured PreconditionEvent if present.
                    $ofPcEvt = Get-IdlePropertyValue -Object $ofStep -Name 'PreconditionEvent'
                    if ($null -ne $ofPcEvt) {
                        $ofPcEvtType = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Type')
                        $ofPcEvtMsg = [string](Get-IdlePropertyValue -Object $ofPcEvt -Name 'Message')
                        $ofPcEvtData = Get-IdlePropertyValue -Object $ofPcEvt -Name 'Data'
                        $ofPcEvtDataHt = if ($ofPcEvtData -is [System.Collections.IDictionary]) { [hashtable]$ofPcEvtData } else { $null }
                        $context.EventSink.WriteEvent($ofPcEvtType, $ofPcEvtMsg, $ofName, $ofPcEvtDataHt)
                    }

                    if ($ofOnPreconditionFalse -eq 'Fail') {
                        $onFailureHadFailures = $true
                        $onFailureStepResults += [pscustomobject]@{
                            PSTypeName = 'IdLE.StepResult'
                            Name       = $ofName
                            Type       = $ofType
                            Status     = 'Failed'
                            Error      = 'Precondition check failed.'
                            Attempts   = 0
                        }
                        $context.EventSink.WriteEvent(
                            'StepFailed',
                            "OnFailure step '$ofName' failed (precondition check failed).",
                            $ofName,
                            @{
                                StepType = $ofType
                                Index    = $j
                                Error    = 'Precondition check failed.'
                            }
                        )
                    }
                    else {
                        # Blocked or Continue: skip this OnFailure step and proceed to the next.
                        $ofStatus = if ($ofOnPreconditionFalse -eq 'Continue') { 'PreconditionSkipped' } else { 'Blocked' }
                        $onFailureStepResults += [pscustomobject]@{
                            PSTypeName = 'IdLE.StepResult'
                            Name       = $ofName
                            Type       = $ofType
                            Status     = $ofStatus
                            Attempts   = 0
                        }
                        if ($ofOnPreconditionFalse -ne 'Continue') {
                            $context.EventSink.WriteEvent(
                                'StepBlocked',
                                "OnFailure step '$ofName' blocked (precondition check failed).",
                                $ofName,
                                @{
                                    StepType = $ofType
                                    Index    = $j
                                }
                            )
                        }
                    }

                    $j++
                    continue
                }
            }

            $context.EventSink.WriteEvent(
                'OnFailureStepStarted',
                "OnFailure step '$ofName' started.",
                $ofName,
                @{
                    StepType = $ofType
                    Index    = $j
                }
            )

            try {
                $impl = Resolve-IdleStepHandler -StepType ([string]$ofType) -StepRegistry $stepRegistry

                $supportedParams = Get-IdleCommandParameterNames -Handler $impl

                $invokeParams = @{}

                # Backwards compatibility: pass -Context only when the handler supports it.
                if ($supportedParams.Contains('Context')) {
                    $invokeParams.Context = $context
                }

                if ($null -ne $ofWith -and $supportedParams.Contains('With')) {
                    $invokeParams.With = $ofWith
                }

                if ($supportedParams.Contains('Step')) {
                    $invokeParams.Step = $ofStep
                }

                # Reuse safe-by-default transient retries for OnFailure steps.
                # - Retry parameters resolved from ExecutionOptions if provided.
                $retrySeed = "$corr|OnFailure|$ofType|$ofName|$j"
                $retryParams = Resolve-IdleStepRetryParameters -Step $ofStep -ExecutionOptions $ExecutionOptions
                $retry = Invoke-IdleWithRetry `
                    -Operation { & $impl @invokeParams } `
                    -MaxAttempts $retryParams.MaxAttempts `
                    -InitialDelayMilliseconds $retryParams.InitialDelayMilliseconds `
                    -BackoffFactor $retryParams.BackoffFactor `
                    -MaxDelayMilliseconds $retryParams.MaxDelayMilliseconds `
                    -JitterRatio $retryParams.JitterRatio `
                    -EventSink $context.EventSink `
                    -StepName $ofName `
                    -OperationName 'OnFailureStepExecution' `
                    -DeterministicSeed $retrySeed

                $result = $retry.Value
                $attempts = [int]$retry.Attempts

                if ($null -eq $result) {
                    $result = [pscustomobject]@{
                        PSTypeName = 'IdLE.StepResult'
                        Name       = $ofName
                        Type       = $ofType
                        Status     = 'Completed'
                        Attempts   = $attempts
                    }
                }
                else {
                    # Normalize result to include Attempts for observability (non-breaking).
                    if ($result.PSObject.Properties.Name -notcontains 'Attempts') {
                        $null = $result | Add-Member -MemberType NoteProperty -Name Attempts -Value $attempts -Force
                    }
                }

                $onFailureStepResults += $result

                if ($result.Status -eq 'Failed') {
                    $onFailureHadFailures = $true

                    $context.EventSink.WriteEvent(
                        'OnFailureStepFailed',
                        "OnFailure step '$ofName' failed.",
                        $ofName,
                        @{
                            StepType = $ofType
                            Index    = $j
                            Error    = $result.Error
                        }
                    )
                }
                else {
                    $context.EventSink.WriteEvent(
                        'OnFailureStepCompleted',
                        "OnFailure step '$ofName' completed.",
                        $ofName,
                        @{
                            StepType = $ofType
                            Index    = $j
                        }
                    )
                }
            }
            catch {
                $onFailureHadFailures = $true
                $err = $_

                $onFailureStepResults += [pscustomobject]@{
                    PSTypeName = 'IdLE.StepResult'
                    Name       = $ofName
                    Type       = $ofType
                    Status     = 'Failed'
                    Error      = $err.Exception.Message
                    Attempts   = 1
                }

                $context.EventSink.WriteEvent(
                    'OnFailureStepFailed',
                    "OnFailure step '$ofName' failed.",
                    $ofName,
                    @{
                        StepType = $ofType
                        Index    = $j
                        Error    = $err.Exception.Message
                    }
                )
            }

            $j++
        }

        $onFailureStatus = if ($onFailureHadFailures) { 'PartiallyFailed' } else { 'Completed' }

        $onFailure = [pscustomobject]@{
            PSTypeName = 'IdLE.OnFailureExecutionResult'
            Status     = $onFailureStatus
            Steps      = @($onFailureStepResults)
        }

        $context.EventSink.WriteEvent(
            'OnFailureCompleted',
            "OnFailureSteps finished (status: $onFailureStatus).",
            $null,
            @{
                Status    = $onFailureStatus
                StepCount = @($planOnFailureSteps).Count
            }
        )
    }

    # RunCompleted should always be the last event for deterministic event order.
    $context.EventSink.WriteEvent(
        'RunCompleted',
        "Plan execution finished (status: $runStatus).",
        $null,
        @{
            Status    = $runStatus
            StepCount = @($Plan.Steps).Count
        }
    )

    # Issue #48:
    # Redact provider configuration/state at the output boundary (execution result).
    $redactedProviders = if ($null -ne $effectiveProviders) {
        Copy-IdleRedactedObject -Value $effectiveProviders
    }
    else {
        $null
    }

    return [pscustomobject]@{
        PSTypeName    = 'IdLE.ExecutionResult'
        Status        = $runStatus
        CorrelationId = $corr
        Actor         = $actor
        Steps         = @($stepResults)
        OnFailure     = $onFailure
        Events        = $events
        Providers     = $redactedProviders
    }
}