From 92e3ade5e3e58b62714689b5ea13a71b72cac112 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:35:26 +0100 Subject: [PATCH 01/21] core: allow OnFailureSteps in workflow schema --- .../Private/Test-IdleWorkflowSchema.ps1 | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index 1b434f2d..be47db5c 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -8,7 +8,7 @@ function Test-IdleWorkflowSchema { # Strict validation: collect all schema violations and return them as a list. $errors = [System.Collections.Generic.List[string]]::new() - $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'Description') + $allowedRootKeys = @('Name', 'LifecycleEvent', 'Steps', 'OnFailureSteps', 'Description') foreach ($key in $Workflow.Keys) { if ($allowedRootKeys -notcontains $key) { $errors.Add("Unknown root key '$key'. Allowed keys: $($allowedRootKeys -join ', ').") @@ -77,5 +77,59 @@ function Test-IdleWorkflowSchema { } } + # OnFailureSteps are optional. If present, validate them like regular Steps. + if ($Workflow.ContainsKey('OnFailureSteps') -and $null -ne $Workflow.OnFailureSteps) { + if ($Workflow.OnFailureSteps -isnot [System.Collections.IEnumerable] -or $Workflow.OnFailureSteps -is [string]) { + $errors.Add("'OnFailureSteps' must be an array/list of step hashtables.") + } + else { + $stepNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + $i = 0 + foreach ($step in $Workflow.OnFailureSteps) { + $stepPath = "OnFailureSteps[$i]" + + if ($null -eq $step -or $step -isnot [hashtable]) { + $errors.Add("$stepPath must be a hashtable.") + $i++ + continue + } + + $allowedStepKeys = @('Name', 'Type', 'Condition', 'With', 'Description', 'RequiresCapabilities') + foreach ($k in $step.Keys) { + if ($allowedStepKeys -notcontains $k) { + $errors.Add("Unknown key '$k' in $stepPath. Allowed keys: $($allowedStepKeys -join ', ').") + } + } + + if (-not $step.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$step.Name)) { + $errors.Add("Missing or empty required key '$stepPath.Name'.") + } + else { + if (-not $stepNames.Add([string]$step.Name)) { + $errors.Add("Duplicate step name '$($step.Name)' detected in 'OnFailureSteps'. Step names must be unique.") + } + } + + if (-not $step.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$step.Type)) { + $errors.Add("Missing or empty required key '$stepPath.Type'.") + } + + # Conditions must be declarative data, never a ScriptBlock/expression. + # We only enforce the shape here; semantic validation comes later. + if ($step.ContainsKey('Condition') -and $null -ne $step.Condition -and $step.Condition -isnot [hashtable]) { + $errors.Add("'$stepPath.Condition' must be a hashtable (declarative condition object).") + } + + # 'With' is step parameter bag (data-only). Detailed validation comes with step metadata later. + if ($step.ContainsKey('With') -and $null -ne $step.With -and $step.With -isnot [hashtable]) { + $errors.Add("'$stepPath.With' must be a hashtable (step parameters).") + } + + $i++ + } + } + } + return $errors } From a15c128dde18abf1d0997b6743e4df528c17f36d Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:50:12 +0100 Subject: [PATCH 02/21] core: validate and normalize OnFailureSteps in workflow definition --- .../Public/Test-IdleWorkflowDefinitionObject.ps1 | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 index 7449cfd7..8f61bfce 100644 --- a/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 +++ b/src/IdLE.Core/Public/Test-IdleWorkflowDefinitionObject.ps1 @@ -68,6 +68,21 @@ function Test-IdleWorkflowDefinitionObject { $idx++ } + # 4c) Validate OnFailureSteps definitions (Name/Type/Condition/With + data-only). + # These are executed only when a run fails, but they must be valid workflow steps. + if ($workflow.ContainsKey('OnFailureSteps') -and $null -ne $workflow.OnFailureSteps) { + $idx = 0 + foreach ($s in @($workflow.OnFailureSteps)) { + $stepErrors = Test-IdleStepDefinition -Step $s -Index $idx + foreach ($e in @($stepErrors)) { + # Re-label errors so operators can clearly distinguish the step collection. + $normalizedError = ([string]$e) -replace '^Step\[(\d+)\]', 'OnFailureSteps[$1]' + $null = $errors.Add($normalizedError) + } + $idx++ + } + } + if ($errors.Count -gt 0) { # Fail early with a single terminating exception, including all violations. $message = "Workflow validation failed:`n- " + ($errors -join "`n- ") @@ -82,5 +97,6 @@ function Test-IdleWorkflowDefinitionObject { LifecycleEvent = [string]$workflow.LifecycleEvent Description = if ($workflow.ContainsKey('Description')) { [string]$workflow.Description } else { $null } Steps = @($workflow.Steps) + OnFailureSteps = if ($workflow.ContainsKey('OnFailureSteps') -and $null -ne $workflow.OnFailureSteps) { @($workflow.OnFailureSteps) } else { @() } } } From d0e925cd1e8a75cbe1fc0617689fa686dedb6d54 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 18:56:40 +0100 Subject: [PATCH 03/21] core: allow OnFailureSteps in workflow schema --- src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 index be47db5c..a245bf93 100644 --- a/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 +++ b/src/IdLE.Core/Private/Test-IdleWorkflowSchema.ps1 @@ -83,7 +83,7 @@ function Test-IdleWorkflowSchema { $errors.Add("'OnFailureSteps' must be an array/list of step hashtables.") } else { - $stepNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $failureStepNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $i = 0 foreach ($step in $Workflow.OnFailureSteps) { @@ -106,8 +106,8 @@ function Test-IdleWorkflowSchema { $errors.Add("Missing or empty required key '$stepPath.Name'.") } else { - if (-not $stepNames.Add([string]$step.Name)) { - $errors.Add("Duplicate step name '$($step.Name)' detected in 'OnFailureSteps'. Step names must be unique.") + if (-not $failureStepNames.Add([string]$step.Name)) { + $errors.Add("Duplicate step name '$($step.Name)' detected in 'OnFailureSteps'. Step names must be unique within this collection.") } } From 0fe8cde3b6d753a06cd902270a702164cf527945 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 19:07:28 +0100 Subject: [PATCH 04/21] core: include OnFailureSteps in plan object and capability validation --- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 93 ++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 0d7d274f..8d50bf0d 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -400,6 +400,7 @@ function New-IdlePlanObject { Actor = $requestSnapshot.Actor CreatedUtc = [DateTime]::UtcNow Steps = @() + OnFailureSteps = @() Actions = @() Warnings = @() Providers = $Providers @@ -560,11 +561,101 @@ function New-IdlePlanObject { } } + # Normalize OnFailureSteps into the same internal representation as regular Steps. + # These steps are executed only when the run fails (best effort), but they are planned and validated + # upfront to keep execution deterministic. + $normalizedOnFailureSteps = @() + foreach ($s in @($workflow.OnFailureSteps)) { + $stepName = if (Test-IdleWorkflowStepKey -Step $s -Key 'Name') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Name') + } + else { + '' + } + + if ([string]::IsNullOrWhiteSpace($stepName)) { + throw [System.ArgumentException]::new('OnFailureSteps entry is missing required key "Name".', 'Workflow') + } + + $stepType = if (Test-IdleWorkflowStepKey -Step $s -Key 'Type') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Type') + } + else { + '' + } + + if ([string]::IsNullOrWhiteSpace($stepType)) { + throw [System.ArgumentException]::new(("OnFailureSteps step '{0}' is missing required key 'Type'." -f $stepName), 'Workflow') + } + + if (Test-IdleWorkflowStepKey -Step $s -Key 'When') { + throw [System.ArgumentException]::new( + ("OnFailureSteps step '{0}' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition." -f $stepName), + 'Workflow' + ) + } + + $condition = if (Test-IdleWorkflowStepKey -Step $s -Key 'Condition') { + Get-IdleWorkflowStepValue -Step $s -Key 'Condition' + } + else { + $null + } + + $status = 'Planned' + if ($null -ne $condition) { + $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName $stepName + if (@($schemaErrors).Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Condition on OnFailureSteps step '{0}': {1}" -f $stepName, ([string]::Join(' ', @($schemaErrors)))), + 'Workflow' + ) + } + + $isApplicable = Test-IdleCondition -Condition $condition -Context $planningContext + if (-not $isApplicable) { + $status = 'NotApplicable' + } + } + + $requiresCaps = @() + if (Test-IdleWorkflowStepKey -Step $s -Key 'RequiresCapabilities') { + $requiresCaps = Normalize-IdleRequiredCapabilities -Value (Get-IdleWorkflowStepValue -Step $s -Key 'RequiresCapabilities') -StepName $stepName + } + + $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Description') + } + else { + '' + } + + $with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') { + Get-IdleWorkflowStepValue -Step $s -Key 'With' + } + else { + @{} + } + + $normalizedOnFailureSteps += [pscustomobject]@{ + PSTypeName = 'IdLE.PlanStep' + Name = $stepName + Type = $stepType + Description = $description + Condition = $condition + With = $with + RequiresCapabilities = $requiresCaps + Status = $status + } + } + # Attach steps to the plan after normalization. $plan.Steps = $normalizedSteps + $plan.OnFailureSteps = $normalizedOnFailureSteps # Fail-fast capability validation (only if at least one step declares requirements). - Assert-IdlePlanCapabilitiesSatisfied -Steps $plan.Steps -Providers $Providers + # We validate both regular steps and OnFailureSteps upfront to keep plans deterministic. + Assert-IdlePlanCapabilitiesSatisfied -Steps @($plan.Steps + $plan.OnFailureSteps) -Providers $Providers return $plan } From 185deff1e7224c201906af4050aa659c7b0f3698 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 20:21:19 +0100 Subject: [PATCH 05/21] core: execute OnFailureSteps on failure (best effort) --- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 878 ++++++++------------ 1 file changed, 346 insertions(+), 532 deletions(-) diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 8d50bf0d..91fc0994 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -1,661 +1,475 @@ -function New-IdlePlanObject { +function Invoke-IdlePlanObject { <# .SYNOPSIS - Builds a deterministic plan from a request and a workflow definition. + Executes an IdLE plan object and returns a deterministic execution result. .DESCRIPTION - Loads and validates the workflow definition (PSD1) and creates a normalized plan object. - This is a planning-only artifact. Execution is handled by Invoke-IdlePlan later. + Executes steps in order, emits structured events, and returns a stable execution result. - .PARAMETER WorkflowPath - Path to the workflow definition (PSD1). + Security: + - ScriptBlocks are rejected in plan and providers. + - The returned execution result is an output boundary: Providers are redacted. - .PARAMETER Request - Lifecycle request object (must contain LifecycleEvent and CorrelationId). + .PARAMETER Plan + Plan object created by New-IdlePlanObject. .PARAMETER Providers - Provider map passed through to the plan for later execution. + Provider registry/collection (may be passed through by the host). + + .PARAMETER EventSink + Optional external sink for events. Must be an object with WriteEvent(event) method. .OUTPUTS - PSCustomObject (PSTypeName: IdLE.Plan) + PSCustomObject (PSTypeName: IdLE.ExecutionResult) #> [CmdletBinding()] param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $WorkflowPath, - [Parameter(Mandatory)] [ValidateNotNull()] - [object] $Request, + [object] $Plan, [Parameter()] [AllowNull()] - [object] $Providers - ) - - function ConvertTo-NullIfEmptyString { - [CmdletBinding()] - param( - [Parameter()] - [object] $Value - ) - - if ($null -eq $Value) { - return $null - } + [hashtable] $Providers, - if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { - return $null - } + [Parameter()] + [AllowNull()] + [object] $EventSink + ) - return $Value - } + Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' + Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' - function Copy-IdleDataObject { + function Get-IdleCommandParameterNames { [CmdletBinding()] param( - [Parameter()] - [object] $Value + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Handler ) - if ($null -eq $Value) { - return $null - } + # Returns a HashSet[string] of parameter names supported by the handler. + $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - # Primitive / immutable-ish types can be returned as-is. - if ($Value -is [string] -or - $Value -is [int] -or - $Value -is [long] -or - $Value -is [double] -or - $Value -is [decimal] -or - $Value -is [bool] -or - $Value -is [datetime] -or - $Value -is [guid]) { - return $Value - } + if ($Handler -is [scriptblock]) { - # Hashtable / IDictionary -> clone recursively. - if ($Value -is [System.Collections.IDictionary]) { - $copy = @{} - foreach ($k in $Value.Keys) { - $copy[$k] = Copy-IdleDataObject -Value $Value[$k] + $paramBlock = $Handler.Ast.ParamBlock + if ($null -eq $paramBlock) { + return $set } - return $copy - } - # Arrays / enumerables -> clone recursively. - if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { - $items = @() - foreach ($item in $Value) { - $items += Copy-IdleDataObject -Value $item + foreach ($p in $paramBlock.Parameters) { + # Parameter name is stored as '$name' in the AST; we normalize to 'name' + $name = $p.Name.VariablePath.UserPath + if (-not [string]::IsNullOrWhiteSpace($name)) { + [void]$set.Add([string]$name) + } } - return $items + + return $set } - # PSCustomObject and other objects -> shallow map of public properties (data-only). - $props = $Value.PSObject.Properties | Where-Object { $_.MemberType -eq 'NoteProperty' -or $_.MemberType -eq 'Property' } - if ($null -ne $props -and @($props).Count -gt 0) { - $copy = @{} - foreach ($p in $props) { - $copy[$p.Name] = Copy-IdleDataObject -Value $p.Value - } - return [pscustomobject] $copy + $meta = $Handler | Get-Command | Select-Object -ExpandProperty Parameters + foreach ($k in $meta.Keys) { + [void]$set.Add([string]$k) } - # Fallback: return string representation (keeps export stable without leaking runtime handles). - return [string] $Value + return $set } - function Normalize-IdleRequiredCapabilities { - <# - .SYNOPSIS - Normalizes the optional RequiresCapabilities key from a workflow step. - - .DESCRIPTION - A workflow step may declare required capabilities via RequiresCapabilities. - Supported shapes: - - missing / $null -> empty list - - string -> single capability - - array/enumerable of strings -> list of capabilities - - The output is a stable, sorted, unique string array. - #> + function Resolve-IdleStepHandler { [CmdletBinding()] param( - [Parameter()] - [object] $Value, - [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $StepName - ) - - if ($null -eq $Value) { - return @() - } + [string] $StepType, - $items = @() - - if ($Value -is [string]) { - $items = @($Value) - } - elseif ($Value -is [System.Collections.IEnumerable]) { - foreach ($v in $Value) { - $items += $v - } - } - else { - throw [System.ArgumentException]::new( - ("Workflow step '{0}' has invalid RequiresCapabilities value. Expected string or string array." -f $StepName), - 'Workflow' - ) - } + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $StepRegistry + ) - $normalized = @() - foreach ($c in $items) { - if ($null -eq $c) { - continue - } + # Current shape: hashtable mapping step type -> function name (string) + if ($StepRegistry -is [hashtable]) { - $s = ([string]$c).Trim() - if ([string]::IsNullOrWhiteSpace($s)) { - continue + if (-not $StepRegistry.ContainsKey($StepType)) { + throw [System.ArgumentException]::new( + "No step handler registered for step type '$StepType'.", + 'Providers' + ) } - # Keep convention aligned with Get-IdleProviderCapabilities: - # - dot-separated segments - # - no whitespace - # - starts with a letter - if ($s -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { + $handler = $StepRegistry[$StepType] + if ($handler -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$handler)) { throw [System.ArgumentException]::new( - ("Workflow step '{0}' declares invalid capability '{1}'. Expected dot-separated segments like 'Identity.Read'." -f $StepName, $s), - 'Workflow' + "Step handler for step type '$StepType' must be a non-empty string (function name).", + 'Providers' ) } - $normalized += $s + return ([string]$handler).Trim() } - return @($normalized | Sort-Object -Unique) - } - - function Get-IdleProvidersFromMap { - <# - .SYNOPSIS - Extracts provider instances from the -Providers argument. - - .DESCRIPTION - The engine currently treats -Providers as a host-controlled bag of objects. - This function extracts candidate provider objects for capability discovery. - - Supported shapes: - - $null -> no providers - - hashtable -> iterate values, ignoring known non-provider keys like 'StepRegistry' - - PSCustomObject -> read public properties as provider entries - - This is intentionally conservative: only values that look like provider instances - (non-null objects) are returned. - #> - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object] $Providers - ) - - if ($null -eq $Providers) { - return @() + # Backward-compatible shape: registry object with GetStep(string) method. + if ($StepRegistry.PSObject.Methods.Name -contains 'GetStep') { + return $StepRegistry.GetStep($StepType) } - $result = @() - - if ($Providers -is [hashtable]) { - foreach ($k in $Providers.Keys) { - # 'StepRegistry' is explicitly not a provider; it is a host extension point. - if ([string]$k -eq 'StepRegistry') { - continue - } - - $v = $Providers[$k] - if ($null -ne $v) { - $result += $v - } - } - - return $result - } + throw [System.ArgumentException]::new( + 'Step registry must be a hashtable mapping Step.Type to a handler function name (string).', + 'Providers' + ) + } - # Allow an object bag (Providers.IdentityProvider, Providers.Directory, ...). - $props = @($Providers.PSObject.Properties) - foreach ($p in $props) { - if ($p.MemberType -ne 'NoteProperty' -and $p.MemberType -ne 'Property') { - continue - } + $events = [System.Collections.Generic.List[object]]::new() - if ([string]$p.Name -eq 'StepRegistry') { - continue - } + # Resolve request/correlation/actor early because New-IdleEventSink requires CorrelationId. + $planPropNames = @($Plan.PSObject.Properties.Name) - if ($null -ne $p.Value) { - $result += $p.Value - } - } + $request = if ($planPropNames -contains 'Request') { $Plan.Request } else { $null } + $requestPropNames = if ($null -ne $request) { @($request.PSObject.Properties.Name) } else { @() } - return $result + $corr = if ($null -ne $request -and $requestPropNames -contains 'CorrelationId') { + $request.CorrelationId + } + else { + if ($planPropNames -contains 'CorrelationId') { $Plan.CorrelationId } else { $null } } - function Get-IdleAvailableCapabilities { - <# - .SYNOPSIS - Builds a stable set of capabilities available from the provided providers. + $actor = if ($null -ne $request -and $requestPropNames -contains 'Actor') { + $request.Actor + } + else { + if ($planPropNames -contains 'Actor') { $Plan.Actor } else { $null } + } - .DESCRIPTION - Capabilities are discovered from each provider via Get-IdleProviderCapabilities. - During the migration phase we allow minimal inference for legacy providers - to avoid breaking existing demos/tests. + # Optional OnFailureSteps are planned but only executed when the run fails. + $onFailureSteps = if ($planPropNames -contains 'OnFailureSteps' -and $null -ne $Plan.OnFailureSteps) { + @($Plan.OnFailureSteps) + } + else { + @() + } - The returned list is stable (sorted, unique). - #> - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object] $Providers - ) + $onFailureStepResults = @() + $onFailureStatus = 'NotRun' - $all = @() + # 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 - foreach ($p in @(Get-IdleProvidersFromMap -Providers $Providers)) { - # AllowInference is a migration aid. Once the ecosystem advertises capabilities - # explicitly, we can tighten this to explicit-only for maximum determinism. - $all += @(Get-IdleProviderCapabilities -Provider $p -AllowInference) - } + # StepRegistry is constructed via helper to ensure built-in steps and host-provided steps can co-exist. + $stepRegistry = Get-IdleStepRegistry -Providers $Providers - return @($all | Sort-Object -Unique) + $context = [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionContext' + Plan = $Plan + Providers = $Providers + EventSink = $engineEventSink } - function Assert-IdlePlanCapabilitiesSatisfied { - <# - .SYNOPSIS - Validates that all required step capabilities are available. - - .DESCRIPTION - This is a fail-fast validation executed during planning. - If one or more capabilities are missing, an ArgumentException is thrown with a - deterministic error message that lists missing capabilities and affected steps. + $context.EventSink.WriteEvent('RunStarted', "Plan execution started (correlationId: $corr).", $null, @{ + CorrelationId = $corr + Actor = $actor + StepCount = @($Plan.Steps).Count + OnFailureStepCount = @($onFailureSteps).Count + }) - No-op when the plan contains no steps. - #> - [CmdletBinding()] - param( - [Parameter()] - [AllowNull()] - [object[]] $Steps, + $failed = $false + $stepResults = @() - [Parameter()] - [AllowNull()] - [object] $Providers - ) + $i = 0 + foreach ($step in $Plan.Steps) { - if ($null -eq $Steps -or @($Steps).Count -eq 0) { - return + if ($null -eq $step) { + continue } - $required = @() - $requiredByStep = @{} + $stepPropNames = @($step.PSObject.Properties.Name) - foreach ($s in @($Steps)) { - $stepName = if ($s.PSObject.Properties.Name -contains 'Name') { [string]$s.Name } else { '' } - $caps = @() + $stepName = if ($stepPropNames -contains 'Name') { $step.Name } else { $null } + $stepType = if ($stepPropNames -contains 'Type') { $step.Type } else { $null } + $stepWith = if ($stepPropNames -contains 'With') { $step.With } else { $null } + $stepStatus = if ($stepPropNames -contains 'Status') { [string]$step.Status } else { '' } - if ($s.PSObject.Properties.Name -contains 'RequiresCapabilities') { - $caps = @($s.RequiresCapabilities) - } + # Conditions are evaluated during planning and represented as Step.Status. + if ($stepStatus -eq 'NotApplicable') { - if (@($caps).Count -gt 0) { - $required += $caps - $requiredByStep[$stepName] = @($caps) + $stepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'NotApplicable' + Attempts = 1 } - } - $required = @($required | Sort-Object -Unique) + $context.EventSink.WriteEvent('StepNotApplicable', "Step '$stepName' not applicable (condition not met).", $stepName, @{ + StepType = $stepType + Index = $i + }) - # Nothing required -> nothing to validate. - if (@($required).Count -eq 0) { - return + $i++ + continue } - $available = @(Get-IdleAvailableCapabilities -Providers $Providers) + $context.EventSink.WriteEvent('StepStarted', "Step '$stepName' started.", $stepName, @{ + StepType = $stepType + Index = $i + }) - $missing = @() - foreach ($c in $required) { - if ($available -notcontains $c) { - $missing += $c - } - } + try { + $impl = Resolve-IdleStepHandler -StepType ([string]$stepType) -StepRegistry $stepRegistry - $missing = @($missing | Sort-Object -Unique) + $supportedParams = Get-IdleCommandParameterNames -Handler $impl - if (@($missing).Count -eq 0) { - return - } - - # Determine which steps are affected for better UX. - $affectedSteps = @() - foreach ($k in $requiredByStep.Keys) { - $caps = @($requiredByStep[$k]) - foreach ($m in $missing) { - if ($caps -contains $m) { - $affectedSteps += $k - break - } + $invokeParams = @{ + Context = $context } - } - $affectedSteps = @($affectedSteps | Sort-Object -Unique) - - $msg = @() - $msg += "Plan cannot be built because required provider capabilities are missing." - $msg += ("MissingCapabilities: {0}" -f ([string]::Join(', ', @($missing)))) - $msg += ("AffectedSteps: {0}" -f ([string]::Join(', ', @($affectedSteps)))) - $msg += ("AvailableCapabilities: {0}" -f ([string]::Join(', ', @($available)))) + if ($null -ne $stepWith -and $supportedParams.Contains('With')) { + $invokeParams.With = $stepWith + } - throw [System.ArgumentException]::new(([string]::Join(' ', $msg)), 'Providers') - } + if ($supportedParams.Contains('Step')) { + $invokeParams.Step = $step + } - # Ensure required request properties exist without hard-typing the request class. - $reqProps = $Request.PSObject.Properties.Name - if ($reqProps -notcontains 'LifecycleEvent') { - throw [System.ArgumentException]::new("Request object must contain property 'LifecycleEvent'.", 'Request') - } - if ($reqProps -notcontains 'CorrelationId') { - throw [System.ArgumentException]::new("Request object must contain property 'CorrelationId'.", 'Request') - } + # Safe-by-default transient retries: + # - Only retries if the thrown exception is explicitly marked transient. + # - Emits 'StepRetrying' events and uses deterministic jitter/backoff. + $retrySeed = "$corr|$stepType|$stepName|$i" + $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -EventSink $context.EventSink -StepName ([string]$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 + } + } - # Create a data-only snapshot of the incoming request. - # This is required for auditing/approvals and for deterministic plan export artifacts. - # We intentionally store a snapshot (not a reference) to avoid accidental mutations later. - $requestSnapshot = [pscustomobject]@{ - PSTypeName = 'IdLE.LifecycleRequestSnapshot' - LifecycleEvent = ConvertTo-NullIfEmptyString -Value ([string] $Request.LifecycleEvent) - CorrelationId = ConvertTo-NullIfEmptyString -Value ([string] $Request.CorrelationId) - Actor = if ($reqProps -contains 'Actor') { ConvertTo-NullIfEmptyString -Value ([string] $Request.Actor) } else { $null } - IdentityKeys = if ($reqProps -contains 'IdentityKeys') { Copy-IdleDataObject -Value $Request.IdentityKeys } else { $null } - DesiredState = if ($reqProps -contains 'DesiredState') { Copy-IdleDataObject -Value $Request.DesiredState } else { $null } - Changes = if ($reqProps -contains 'Changes') { Copy-IdleDataObject -Value $Request.Changes } else { $null } - } + $stepResults += $result - # Validate workflow and ensure it matches the request's LifecycleEvent. - $workflow = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request - - # Create the plan object (planning artifact). - # Steps will be populated after we have a stable plan context for condition evaluation. - $plan = [pscustomobject]@{ - PSTypeName = 'IdLE.Plan' - WorkflowName = [string]$workflow.Name - LifecycleEvent = [string]$workflow.LifecycleEvent - CorrelationId = [string]$requestSnapshot.CorrelationId - Request = $requestSnapshot - Actor = $requestSnapshot.Actor - CreatedUtc = [DateTime]::UtcNow - Steps = @() - OnFailureSteps = @() - Actions = @() - Warnings = @() - Providers = $Providers - } + if ($result.Status -eq 'Failed') { + $failed = $true - # Build a planning context for condition evaluation. - # This allows conditions to reference "Plan.*" paths (e.g. Plan.LifecycleEvent). - $planningContext = [pscustomobject]@{ - Plan = $plan - Request = $Request - Workflow = $workflow - } + $context.EventSink.WriteEvent('StepFailed', "Step '$stepName' failed.", $stepName, @{ + StepType = $stepType + Index = $i + Error = $result.Error + }) - function Test-IdleWorkflowStepKey { - <# - .SYNOPSIS - Checks whether a workflow step contains a given key. + break + } - .DESCRIPTION - Workflow steps can be represented as hashtables (IDictionary) or as objects - (PSCustomObject) depending on how they were imported. This helper provides a - stable way to check for keys across both representations. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Step, + $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 + } - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Key - ) + $context.EventSink.WriteEvent('StepFailed', "Step '$stepName' failed.", $stepName, @{ + StepType = $stepType + Index = $i + Error = $err.Exception.Message + }) - if ($Step -is [System.Collections.IDictionary]) { - return $Step.ContainsKey($Key) + break } - return ($Step.PSObject.Properties.Name -contains $Key) + $i++ } - function Get-IdleWorkflowStepValue { - <# - .SYNOPSIS - Gets a value from a workflow step by key. - - .DESCRIPTION - Workflow steps can be represented as hashtables (IDictionary) or as objects - (PSCustomObject) depending on how they were imported. This helper provides a - stable way to read values across both representations. - - IMPORTANT: - Call Test-IdleWorkflowStepKey before calling this function when the key may be optional. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Step, + # Issue #12: + # If the primary run fails, execute OnFailureSteps with best effort. + # - No transient retries in this phase. + # - Failures are recorded, but execution continues for remaining OnFailureSteps. + if ($failed -and @($onFailureSteps).Count -gt 0) { - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $Key - ) + $context.EventSink.WriteEvent('OnFailureStarted', 'OnFailure execution started.', $null, @{ + CorrelationId = $corr + Actor = $actor + StepCount = @($onFailureSteps).Count + }) - if ($Step -is [System.Collections.IDictionary]) { - return $Step[$Key] - } + $onFailureFailed = $false - return $Step.PSObject.Properties[$Key].Value - } - - # Normalize steps into a stable internal representation. - # We deliberately keep step entries as PSCustomObject to avoid cross-module class loading issues. - # Step conditions are evaluated during planning and may mark steps as NotApplicable. - $normalizedSteps = @() - foreach ($s in @($workflow.Steps)) { - $stepName = if (Test-IdleWorkflowStepKey -Step $s -Key 'Name') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Name') - } - else { - '' - } + $j = 0 + foreach ($step in $onFailureSteps) { - if ([string]::IsNullOrWhiteSpace($stepName)) { - throw [System.ArgumentException]::new('Workflow step is missing required key "Name".', 'Workflow') - } + if ($null -eq $step) { + $j++ + continue + } - $stepType = if (Test-IdleWorkflowStepKey -Step $s -Key 'Type') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Type') - } - else { - '' - } + $stepPropNames = @($step.PSObject.Properties.Name) - if ([string]::IsNullOrWhiteSpace($stepType)) { - throw [System.ArgumentException]::new(("Workflow step '{0}' is missing required key 'Type'." -f $stepName), 'Workflow') - } + $stepName = if ($stepPropNames -contains 'Name') { $step.Name } else { $null } + $stepType = if ($stepPropNames -contains 'Type') { $step.Type } else { $null } + $stepWith = if ($stepPropNames -contains 'With') { $step.With } else { $null } + $stepStatus = if ($stepPropNames -contains 'Status') { [string]$step.Status } else { '' } - if (Test-IdleWorkflowStepKey -Step $s -Key 'When') { - throw [System.ArgumentException]::new( - ("Workflow step '{0}' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition." -f $stepName), - 'Workflow' - ) - } + # Conditions are evaluated during planning and represented as Step.Status. + if ($stepStatus -eq 'NotApplicable') { - $condition = if (Test-IdleWorkflowStepKey -Step $s -Key 'Condition') { - Get-IdleWorkflowStepValue -Step $s -Key 'Condition' - } - else { - $null - } + $onFailureStepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'NotApplicable' + Attempts = 1 + } - $status = 'Planned' - if ($null -ne $condition) { - $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName $stepName - if (@($schemaErrors).Count -gt 0) { - throw [System.ArgumentException]::new( - ("Invalid Condition on step '{0}': {1}" -f $stepName, ([string]::Join(' ', @($schemaErrors)))), - 'Workflow' - ) - } + $context.EventSink.WriteEvent('OnFailureStepNotApplicable', "OnFailure step '$stepName' not applicable (condition not met).", $stepName, @{ + StepType = $stepType + Index = $j + }) - $isApplicable = Test-IdleCondition -Condition $condition -Context $planningContext - if (-not $isApplicable) { - $status = 'NotApplicable' + $j++ + continue } - } - $requiresCaps = @() - if (Test-IdleWorkflowStepKey -Step $s -Key 'RequiresCapabilities') { - $requiresCaps = Normalize-IdleRequiredCapabilities -Value (Get-IdleWorkflowStepValue -Step $s -Key 'RequiresCapabilities') -StepName $stepName - } - - $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Description') - } - else { - '' - } - - $with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') { - Get-IdleWorkflowStepValue -Step $s -Key 'With' - } - else { - @{} - } + $context.EventSink.WriteEvent('OnFailureStepStarted', "OnFailure step '$stepName' started.", $stepName, @{ + StepType = $stepType + Index = $j + }) - $normalizedSteps += [pscustomobject]@{ - PSTypeName = 'IdLE.PlanStep' - Name = $stepName - Type = $stepType - Description = $description - Condition = $condition - With = $with - RequiresCapabilities = $requiresCaps - Status = $status - } - } + try { + $impl = Resolve-IdleStepHandler -StepType ([string]$stepType) -StepRegistry $stepRegistry + $supportedParams = Get-IdleCommandParameterNames -Handler $impl - # Normalize OnFailureSteps into the same internal representation as regular Steps. - # These steps are executed only when the run fails (best effort), but they are planned and validated - # upfront to keep execution deterministic. - $normalizedOnFailureSteps = @() - foreach ($s in @($workflow.OnFailureSteps)) { - $stepName = if (Test-IdleWorkflowStepKey -Step $s -Key 'Name') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Name') - } - else { - '' - } + $invokeParams = @{ + Context = $context + } - if ([string]::IsNullOrWhiteSpace($stepName)) { - throw [System.ArgumentException]::new('OnFailureSteps entry is missing required key "Name".', 'Workflow') - } + if ($null -ne $stepWith -and $supportedParams.Contains('With')) { + $invokeParams.With = $stepWith + } - $stepType = if (Test-IdleWorkflowStepKey -Step $s -Key 'Type') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Type') - } - else { - '' - } + if ($supportedParams.Contains('Step')) { + $invokeParams.Step = $step + } - if ([string]::IsNullOrWhiteSpace($stepType)) { - throw [System.ArgumentException]::new(("OnFailureSteps step '{0}' is missing required key 'Type'." -f $stepName), 'Workflow') - } + # Best effort: no transient retries in the OnFailure phase. + $result = & $impl @invokeParams + + if ($null -eq $result) { + $result = [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'Completed' + Attempts = 1 + } + } + 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 1 -Force + } + } - if (Test-IdleWorkflowStepKey -Step $s -Key 'When') { - throw [System.ArgumentException]::new( - ("OnFailureSteps step '{0}' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition." -f $stepName), - 'Workflow' - ) - } + $onFailureStepResults += $result - $condition = if (Test-IdleWorkflowStepKey -Step $s -Key 'Condition') { - Get-IdleWorkflowStepValue -Step $s -Key 'Condition' - } - else { - $null - } + if ($result.Status -eq 'Failed') { + $onFailureFailed = $true - $status = 'Planned' - if ($null -ne $condition) { - $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName $stepName - if (@($schemaErrors).Count -gt 0) { - throw [System.ArgumentException]::new( - ("Invalid Condition on OnFailureSteps step '{0}': {1}" -f $stepName, ([string]::Join(' ', @($schemaErrors)))), - 'Workflow' - ) + $context.EventSink.WriteEvent('OnFailureStepFailed', "OnFailure step '$stepName' failed.", $stepName, @{ + StepType = $stepType + Index = $j + Error = $result.Error + }) + } + else { + $context.EventSink.WriteEvent('OnFailureStepCompleted', "OnFailure step '$stepName' completed.", $stepName, @{ + StepType = $stepType + Index = $j + }) + } } + catch { + $onFailureFailed = $true + $err = $_ + + $onFailureStepResults += [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = $stepName + Type = $stepType + Status = 'Failed' + Error = $err.Exception.Message + Attempts = 1 + } - $isApplicable = Test-IdleCondition -Condition $condition -Context $planningContext - if (-not $isApplicable) { - $status = 'NotApplicable' + $context.EventSink.WriteEvent('OnFailureStepFailed', "OnFailure step '$stepName' failed.", $stepName, @{ + StepType = $stepType + Index = $j + Error = $err.Exception.Message + }) } - } - - $requiresCaps = @() - if (Test-IdleWorkflowStepKey -Step $s -Key 'RequiresCapabilities') { - $requiresCaps = Normalize-IdleRequiredCapabilities -Value (Get-IdleWorkflowStepValue -Step $s -Key 'RequiresCapabilities') -StepName $stepName - } - $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { - [string](Get-IdleWorkflowStepValue -Step $s -Key 'Description') - } - else { - '' + $j++ } - $with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') { - Get-IdleWorkflowStepValue -Step $s -Key 'With' - } - else { - @{} - } + $onFailureStatus = if ($onFailureFailed) { 'PartiallyFailed' } else { 'Completed' } - $normalizedOnFailureSteps += [pscustomobject]@{ - PSTypeName = 'IdLE.PlanStep' - Name = $stepName - Type = $stepType - Description = $description - Condition = $condition - With = $with - RequiresCapabilities = $requiresCaps - Status = $status - } + $context.EventSink.WriteEvent('OnFailureCompleted', "OnFailure execution finished (status: $onFailureStatus).", $null, @{ + Status = $onFailureStatus + StepCount = @($onFailureSteps).Count + }) } - # Attach steps to the plan after normalization. - $plan.Steps = $normalizedSteps - $plan.OnFailureSteps = $normalizedOnFailureSteps + $runStatus = if ($failed) { 'Failed' } else { 'Completed' } - # Fail-fast capability validation (only if at least one step declares requirements). - # We validate both regular steps and OnFailureSteps upfront to keep plans deterministic. - Assert-IdlePlanCapabilitiesSatisfied -Steps @($plan.Steps + $plan.OnFailureSteps) -Providers $Providers + $context.EventSink.WriteEvent('RunCompleted', "Plan execution finished (status: $runStatus).", $null, @{ + Status = $runStatus + StepCount = @($Plan.Steps).Count + OnFailureStatus = $onFailureStatus + OnFailureStepCount = @($onFailureSteps).Count + }) - return $plan + # Issue #48: + # Redact provider configuration/state at the output boundary (execution result). + $redactedProviders = if ($null -ne $Providers) { + Copy-IdleRedactedObject -Value $Providers + } + else { + $null + } + + return [pscustomobject]@{ + PSTypeName = 'IdLE.ExecutionResult' + Status = $runStatus + CorrelationId = $corr + Actor = $actor + Steps = $stepResults + OnFailure = [pscustomobject]@{ + PSTypeName = 'IdLE.OnFailureExecutionResult' + Status = $onFailureStatus + Steps = $onFailureStepResults + } + Events = $events + Providers = $redactedProviders + } } From 726c5314da97b26fb89a66b585921b9547067f47 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 20:37:17 +0100 Subject: [PATCH 06/21] tests: cover OnFailureSteps best-effort execution --- tests/Invoke-IdlePlan.Tests.ps1 | 145 ++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index 09b4ae1b..afd81fc5 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -47,12 +47,34 @@ BeforeAll { Error = $null } } + + function global:Invoke-IdleTestFailStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Failed' + Error = 'Boom' + } + } } AfterAll { # Cleanup global test functions to avoid polluting the session. Remove-Item -Path 'Function:\Invoke-IdleTestNoopStep' -ErrorAction SilentlyContinue Remove-Item -Path 'Function:\Invoke-IdleTestEmitStep' -ErrorAction SilentlyContinue + Remove-Item -Path 'Function:\Invoke-IdleTestFailStep' -ErrorAction SilentlyContinue } Describe 'Invoke-IdlePlan' { @@ -219,6 +241,129 @@ Describe 'Invoke-IdlePlan' { ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 } + It 'executes OnFailureSteps when a step fails (best effort)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'onfailure.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Demo - OnFailure' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'FailPrimary'; Type = 'IdLE.Step.FailPrimary' } + @{ Name = 'NeverRuns'; Type = 'IdLE.Step.NeverRuns' } + ) + OnFailureSteps = @( + @{ Name = 'OnFailure1'; Type = 'IdLE.Step.OnFailure1' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailPrimary' = 'Invoke-IdleTestFailStep' + 'IdLE.Step.NeverRuns' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + @($result.Steps).Count | Should -Be 1 + $result.Steps[0].Name | Should -Be 'FailPrimary' + + $result.OnFailure.PSTypeNames | Should -Contain 'IdLE.OnFailureExecutionResult' + $result.OnFailure.Status | Should -Be 'Completed' + @($result.OnFailure.Steps).Count | Should -Be 1 + $result.OnFailure.Steps[0].Status | Should -Be 'Completed' + + $types = @($result.Events | ForEach-Object { $_.Type }) + $types | Should -Contain 'StepFailed' + $types | Should -Contain 'OnFailureStarted' + $types | Should -Contain 'OnFailureCompleted' + + [array]::IndexOf($types, 'StepFailed') | Should -BeLessThan ([array]::IndexOf($types, 'OnFailureStarted')) + + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 1 + } + + It 'continues OnFailureSteps when an OnFailure step fails (best effort)' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'onfailure-partial.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Demo - OnFailure Partial' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'FailPrimary'; Type = 'IdLE.Step.FailPrimary' } + ) + OnFailureSteps = @( + @{ Name = 'OnFailureFail'; Type = 'IdLE.Step.OnFailureFail' } + @{ Name = 'OnFailureOk'; Type = 'IdLE.Step.OnFailureOk' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.FailPrimary' = 'Invoke-IdleTestFailStep' + 'IdLE.Step.OnFailureFail' = 'Invoke-IdleTestFailStep' + 'IdLE.Step.OnFailureOk' = 'Invoke-IdleTestEmitStep' + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Failed' + $result.OnFailure.Status | Should -Be 'PartiallyFailed' + @($result.OnFailure.Steps).Count | Should -Be 2 + $result.OnFailure.Steps[0].Status | Should -Be 'Failed' + $result.OnFailure.Steps[1].Status | Should -Be 'Completed' + + ($result.Events | Where-Object Type -eq 'OnFailureStepStarted').Count | Should -Be 2 + ($result.Events | Where-Object Type -eq 'OnFailureStepFailed').Count | Should -Be 1 + ($result.Events | Where-Object Type -eq 'OnFailureStepCompleted').Count | Should -Be 1 + } + + It 'does not execute OnFailureSteps when run completes successfully' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'onfailure-notrun.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Demo - OnFailure NotRun' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Ok'; Type = 'IdLE.Step.Ok' } + ) + OnFailureSteps = @( + @{ Name = 'OnFailure1'; Type = 'IdLE.Step.OnFailure1' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Ok' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.OnFailure1' = 'Invoke-IdleTestEmitStep' + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result.Status | Should -Be 'Completed' + $result.OnFailure.Status | Should -Be 'NotRun' + @($result.OnFailure.Steps).Count | Should -Be 0 + + ($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 + ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 + } + It 'fails planning when a step is missing Type' { $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' From 9ebcd1cf33ece5f9d5b62fff1e675678ce97b213 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:17:25 +0100 Subject: [PATCH 07/21] tests: validate provider capabilities for OnFailureSteps --- tests/New-IdlePlan.Capabilities.Tests.ps1 | 106 ++++++++++++++++++---- 1 file changed, 88 insertions(+), 18 deletions(-) diff --git a/tests/New-IdlePlan.Capabilities.Tests.ps1 b/tests/New-IdlePlan.Capabilities.Tests.ps1 index 4f377075..85b962f9 100644 --- a/tests/New-IdlePlan.Capabilities.Tests.ps1 +++ b/tests/New-IdlePlan.Capabilities.Tests.ps1 @@ -15,9 +15,8 @@ Describe 'New-IdlePlan - required provider capabilities' { Steps = @( @{ Name = 'Disable identity' - Type = 'IdLE.Step.EmitEvent' - With = @{ Message = 'Disable identity (planning only test)' } - RequiresCapabilities = 'Identity.Disable' + Type = 'IdLE.Step.DisableIdentity' + RequiresCapabilities = @('Identity.Disable') } ) } @@ -26,29 +25,27 @@ Describe 'New-IdlePlan - required provider capabilities' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $null | Out-Null + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'required provider capabilities are missing' - $_.Exception.Message | Should -Match 'MissingCapabilities:\s+Identity\.Disable' - $_.Exception.Message | Should -Match 'AffectedSteps:\s+Disable identity' + $_.Exception.Message | Should -Match 'MissingCapabilities: Identity\.Disable' + $_.Exception.Message | Should -Match 'AffectedSteps: Disable identity' } } - It 'builds the plan when required capabilities are available' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-capabilities.psd1' + It 'allows planning when a provider advertises the required capabilities' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-capabilities-ok.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'Joiner - Capability Validation' + Name = 'Joiner - Capability Validation OK' LifecycleEvent = 'Joiner' Steps = @( @{ Name = 'Disable identity' - Type = 'IdLE.Step.EmitEvent' - With = @{ Message = 'Disable identity (planning only test)' } - RequiresCapabilities = 'Identity.Disable' + Type = 'IdLE.Step.DisableIdentity' + RequiresCapabilities = @('Identity.Disable') } ) } @@ -56,10 +53,7 @@ Describe 'New-IdlePlan - required provider capabilities' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - # Minimal provider that advertises the required capability. - $provider = [pscustomobject]@{ - Name = 'TestProvider' - } + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { return @('Identity.Disable') } -Force @@ -75,6 +69,82 @@ Describe 'New-IdlePlan - required provider capabilities' { $plan.Steps[0].RequiresCapabilities | Should -Be @('Identity.Disable') } + It 'fails fast when an OnFailure step requires capabilities that no provider advertises' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure-capabilities.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - OnFailure Capability Validation' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Primary step' + Type = 'IdLE.Step.Primary' + } + ) + OnFailureSteps = @( + @{ + Name = 'Containment' + Type = 'IdLE.Step.Containment' + RequiresCapabilities = @('Identity.Disable') + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + try { + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null + throw 'Expected an exception but none was thrown.' + } + catch { + $_.Exception.Message | Should -Match 'MissingCapabilities: Identity\.Disable' + $_.Exception.Message | Should -Match 'AffectedSteps: Containment' + } + } + + It 'includes OnFailureSteps capability requirements in successful planning' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure-capabilities-ok.psd1' + + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - OnFailure Capability Validation OK' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Primary step' + Type = 'IdLE.Step.Primary' + } + ) + OnFailureSteps = @( + @{ + Name = 'Containment' + Type = 'IdLE.Step.Containment' + RequiresCapabilities = @('Identity.Disable') + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + + $provider = [pscustomobject]@{ Name = 'IdentityProvider' } + $provider | Add-Member -MemberType ScriptMethod -Name GetCapabilities -Value { + return @('Identity.Disable') + } -Force + + $providers = @{ + IdentityProvider = $provider + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + $plan.OnFailureSteps.Count | Should -Be 1 + $plan.OnFailureSteps[0].RequiresCapabilities | Should -Be @('Identity.Disable') + } + It 'validates entitlement capabilities for EnsureEntitlement steps' { $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-entitlements.psd1' @@ -96,7 +166,7 @@ Describe 'New-IdlePlan - required provider capabilities' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' try { - New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $null | Out-Null + New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{} | Out-Null throw 'Expected an exception but none was thrown.' } catch { From 9ea5e8f90d66a1dce99688ceacb53f96ee8457ff Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:43:49 +0100 Subject: [PATCH 08/21] tests: assert OnFailureSteps are normalized and default to empty --- tests/New-IdlePlan.Tests.ps1 | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/New-IdlePlan.Tests.ps1 b/tests/New-IdlePlan.Tests.ps1 index c833b980..ddcea388 100644 --- a/tests/New-IdlePlan.Tests.ps1 +++ b/tests/New-IdlePlan.Tests.ps1 @@ -36,6 +36,55 @@ Describe 'New-IdlePlan' { @($plan.Warnings).Count | Should -Be 0 $plan.Providers.Dummy | Should -BeTrue + + # OnFailureSteps are optional in workflows, but the plan should always expose the property + # to keep downstream execution deterministic and avoid "property exists?" checks. + $plan.PSObject.Properties.Name | Should -Contain 'OnFailureSteps' + @($plan.OnFailureSteps).Count | Should -Be 0 + } + + It 'normalizes OnFailureSteps and evaluates their conditions during planning' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - OnFailureSteps' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) + OnFailureSteps = @( + @{ + Name = 'Containment' + Type = 'IdLE.Step.Containment' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + With = @{ Mode = 'Quarantine' } + } + @{ + Name = 'NeverApplicable' + Type = 'IdLE.Step.NeverApplicable' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Mover' } } + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + + $plan | Should -Not -BeNullOrEmpty + $plan.PSObject.Properties.Name | Should -Contain 'OnFailureSteps' + @($plan.OnFailureSteps).Count | Should -Be 2 + + $plan.OnFailureSteps[0].PSTypeNames | Should -Contain 'IdLE.PlanStep' + $plan.OnFailureSteps[0].Name | Should -Be 'Containment' + $plan.OnFailureSteps[0].Type | Should -Be 'IdLE.Step.Containment' + $plan.OnFailureSteps[0].Status | Should -Be 'Planned' + $plan.OnFailureSteps[0].With.Mode | Should -Be 'Quarantine' + + $plan.OnFailureSteps[1].PSTypeNames | Should -Contain 'IdLE.PlanStep' + $plan.OnFailureSteps[1].Name | Should -Be 'NeverApplicable' + $plan.OnFailureSteps[1].Type | Should -Be 'IdLE.Step.NeverApplicable' + $plan.OnFailureSteps[1].Status | Should -Be 'NotApplicable' } It 'throws when request LifecycleEvent does not match workflow LifecycleEvent' { From 252a0ab19a94d946d9df7af58db2b5a55a78699e Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:50:13 +0100 Subject: [PATCH 09/21] tests: validate OnFailureSteps and reject CleanupSteps in workflow validation --- tests/Test-IdleWorkflow.Tests.ps1 | 65 +++++++++++++------------------ 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/tests/Test-IdleWorkflow.Tests.ps1 b/tests/Test-IdleWorkflow.Tests.ps1 index 6ddc7585..6bb6733e 100644 --- a/tests/Test-IdleWorkflow.Tests.ps1 +++ b/tests/Test-IdleWorkflow.Tests.ps1 @@ -17,66 +17,52 @@ Describe 'Test-IdleWorkflow' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' - $result = Test-IdleWorkflow -WorkflowPath $wfPath -Request $req + $result = Test-IdleWorkflow -WorkflowPath $wfPath + $result | Should -Not -BeNullOrEmpty $result.IsValid | Should -BeTrue $result.WorkflowName | Should -Be 'Joiner - Standard' $result.LifecycleEvent | Should -Be 'Joiner' $result.StepCount | Should -Be 1 } - It 'throws for unknown root keys (strict validation)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad-root.psd1' + It 'accepts OnFailureSteps as an optional top-level section' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-onfailure.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'Joiner - Standard' + Name = 'Joiner - OnFailure' LifecycleEvent = 'Joiner' Steps = @( @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } ) - Bogus = 'nope' + OnFailureSteps = @( + @{ Name = 'Containment'; Type = 'IdLE.Step.DisableIdentity' } + ) } '@ - try { - Test-IdleWorkflow -WorkflowPath $wfPath | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'Unknown root key' - } - } + { Test-IdleWorkflow -WorkflowPath $wfPath } | Should -Not -Throw - It 'throws when a step is missing required keys' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad-step.psd1' - Set-Content -Path $wfPath -Encoding UTF8 -Value @' -@{ - Name = 'Joiner - Standard' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'ResolveIdentity' } - ) -} -'@ + $result = Test-IdleWorkflow -WorkflowPath $wfPath + $result.IsValid | Should -BeTrue + $result.WorkflowName | Should -Be 'Joiner - OnFailure' + $result.LifecycleEvent | Should -Be 'Joiner' - try { - Test-IdleWorkflow -WorkflowPath $wfPath | Out-Null - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception.Message | Should -Match 'Steps\[0\]\.Type' - } + # Test-IdleWorkflow returns a small report; StepCount reflects primary Steps only. + $result.StepCount | Should -Be 1 } - It 'throws when the workflow contains ScriptBlocks (data-only rule)' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'bad-sb.psd1' + It 'rejects unknown root keys such as CleanupSteps' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-cleanupsteps.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ - Name = 'Joiner - Standard' + Name = 'Joiner - Invalid' LifecycleEvent = 'Joiner' Steps = @( - @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity'; With = @{ X = { "NOPE" } } } + @{ Name = 'ResolveIdentity'; Type = 'IdLE.Step.ResolveIdentity' } + ) + CleanupSteps = @( + @{ Name = 'Nope'; Type = 'IdLE.Step.DisableIdentity' } ) } '@ @@ -86,12 +72,13 @@ Describe 'Test-IdleWorkflow' { throw 'Expected an exception but none was thrown.' } catch { - $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' + $_.Exception.Message | Should -Match 'Unknown root key' + $_.Exception.Message | Should -Match 'CleanupSteps' } } - It 'throws when request LifecycleEvent does not match workflow LifecycleEvent' { - $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner.psd1' + It 'fails when workflow LifecycleEvent does not match request LifecycleEvent' { + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-mismatch.psd1' Set-Content -Path $wfPath -Encoding UTF8 -Value @' @{ Name = 'Joiner - Standard' From fad9d4fb7ae9c9a5bc0223da6685af2759348ee6 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:58:18 +0100 Subject: [PATCH 10/21] tests: pin public execution result contract for OnFailure --- tests/ModuleSurface.Tests.ps1 | 72 +++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 index 5283b250..943f5181 100644 --- a/tests/ModuleSurface.Tests.ps1 +++ b/tests/ModuleSurface.Tests.ps1 @@ -7,6 +7,34 @@ BeforeAll { . (Join-Path $PSScriptRoot '_testHelpers.ps1') Import-IdleTestModule + + # The engine invokes step handlers by function name (string). + # This handler is used to validate the public output contract of Invoke-IdlePlan + # without relying on built-in step implementations. + function global:Invoke-IdleSurfaceTestNoopStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Context, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step + ) + + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } +} + +AfterAll { + Remove-Item -Path 'Function:\Invoke-IdleSurfaceTestNoopStep' -ErrorAction SilentlyContinue } Describe 'Module manifests and public surface' { @@ -35,6 +63,50 @@ Describe 'Module manifests and public surface' { $actual | Should -Be $expected } + It 'Invoke-IdlePlan returns a public execution result that includes an OnFailure section' { + Remove-Module IdLE -Force -ErrorAction SilentlyContinue + Import-Module $idlePsd1 -Force -ErrorAction Stop + + $wfPath = Join-Path -Path $TestDrive -ChildPath 'surface-onfailure.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Surface - OnFailure Contract' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Primary'; Type = 'IdLE.Step.Primary' } + ) + OnFailureSteps = @( + @{ Name = 'Containment'; Type = 'IdLE.Step.Containment' } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req + + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Primary' = 'Invoke-IdleSurfaceTestNoopStep' + 'IdLE.Step.Containment' = 'Invoke-IdleSurfaceTestNoopStep' + } + } + + $result = Invoke-IdlePlan -Plan $plan -Providers $providers + + $result | Should -Not -BeNullOrEmpty + $result.PSTypeNames | Should -Contain 'IdLE.ExecutionResult' + $result.Status | Should -Be 'Completed' + + # Public result contract: the OnFailure section is always present. + $result.PSObject.Properties.Name | Should -Contain 'OnFailure' + $result.OnFailure.PSTypeNames | Should -Contain 'IdLE.OnFailureExecutionResult' + $result.OnFailure.Status | Should -Be 'NotRun' + @($result.OnFailure.Steps).Count | Should -Be 0 + + # Successful runs must not emit OnFailure events. + ($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 + } + It 'Importing IdLE makes built-in steps available to the engine without exporting them globally' { Remove-Module IdLE, IdLE.Core, IdLE.Steps.Common -Force -ErrorAction SilentlyContinue Import-Module $idlePsd1 -Force -ErrorAction Stop From 9e7da26057f8a656cc042103713586278cc2d70e Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 22:10:38 +0100 Subject: [PATCH 11/21] core: keep WhatIf execution result contract stable (OnFailure section) --- src/IdLE/Public/Invoke-IdlePlan.ps1 | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/IdLE/Public/Invoke-IdlePlan.ps1 b/src/IdLE/Public/Invoke-IdlePlan.ps1 index 1b387b6b..26570ea1 100644 --- a/src/IdLE/Public/Invoke-IdlePlan.ps1 +++ b/src/IdLE/Public/Invoke-IdlePlan.ps1 @@ -40,16 +40,33 @@ function Invoke-IdlePlan { process { if (-not $PSCmdlet.ShouldProcess('IdLE Plan', 'Invoke')) { # For -WhatIf: return a minimal preview object. + # Keep the public output contract stable by always including OnFailure. $correlationId = $null if ($Plan.PSObject.Properties.Name -contains 'CorrelationId') { $correlationId = [string]$Plan.CorrelationId } + $actor = $null + if ($Plan.PSObject.Properties.Name -contains 'Actor') { + $actor = [string]$Plan.Actor + } + elseif ($Plan.PSObject.Properties.Name -contains 'Request' -and $null -ne $Plan.Request) { + if ($Plan.Request.PSObject.Properties.Name -contains 'Actor') { + $actor = [string]$Plan.Request.Actor + } + } + return [pscustomobject]@{ PSTypeName = 'IdLE.ExecutionResult' Status = 'WhatIf' CorrelationId = $correlationId + Actor = $actor Steps = @($Plan.Steps) + OnFailure = [pscustomobject]@{ + PSTypeName = 'IdLE.OnFailureExecutionResult' + Status = 'NotRun' + Steps = @() + } Events = @() } } From e9cec2e30a30a8f9608f81228d262db7b34a6bf8 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Mon, 5 Jan 2026 22:24:52 +0100 Subject: [PATCH 12/21] core: restore New-IdlePlanObject and plan normalization (incl. OnFailureSteps) --- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 791 +++++++++++--------- 1 file changed, 446 insertions(+), 345 deletions(-) diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 91fc0994..d10f4f94 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -1,475 +1,576 @@ -function Invoke-IdlePlanObject { +function New-IdlePlanObject { <# .SYNOPSIS - Executes an IdLE plan object and returns a deterministic execution result. + Builds a deterministic plan from a request and a workflow definition. .DESCRIPTION - Executes steps in order, emits structured events, and returns a stable execution result. + Loads and validates the workflow definition (PSD1) and creates a normalized plan object. + This is a planning-only artifact. Execution is handled by Invoke-IdlePlanObject later. - Security: - - ScriptBlocks are rejected in plan and providers. - - The returned execution result is an output boundary: Providers are redacted. + Planning responsibilities: + - Create a data-only request snapshot for deterministic exports and auditing. + - Normalize workflow steps to IdLE.PlanStep objects. + - Evaluate step conditions during planning and mark steps as NotApplicable. + - Validate required provider capabilities fail-fast (includes OnFailureSteps). - .PARAMETER Plan - Plan object created by New-IdlePlanObject. + .PARAMETER WorkflowPath + Path to the workflow definition (PSD1). - .PARAMETER Providers - Provider registry/collection (may be passed through by the host). + .PARAMETER Request + Lifecycle request object (must contain LifecycleEvent and CorrelationId). - .PARAMETER EventSink - Optional external sink for events. Must be an object with WriteEvent(event) method. + .PARAMETER Providers + Provider map passed through to the plan for later execution. .OUTPUTS - PSCustomObject (PSTypeName: IdLE.ExecutionResult) + PSCustomObject (PSTypeName: IdLE.Plan) #> [CmdletBinding()] param( [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Plan, + [ValidateNotNullOrEmpty()] + [string] $WorkflowPath, - [Parameter()] - [AllowNull()] - [hashtable] $Providers, + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Request, [Parameter()] [AllowNull()] - [object] $EventSink + [object] $Providers ) - Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' - Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' + function ConvertTo-NullIfEmptyString { + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Value + ) + + if ($null -eq $Value) { + return $null + } + + if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + return $Value + } - function Get-IdleCommandParameterNames { + function Copy-IdleDataObject { [CmdletBinding()] param( - [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $Handler + [Parameter()] + [AllowNull()] + [object] $Value ) - # Returns a HashSet[string] of parameter names supported by the handler. - $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + if ($null -eq $Value) { + return $null + } - if ($Handler -is [scriptblock]) { + # Primitive / immutable-ish types can be returned as-is. + if ($Value -is [string] -or + $Value -is [int] -or + $Value -is [long] -or + $Value -is [double] -or + $Value -is [decimal] -or + $Value -is [bool] -or + $Value -is [datetime] -or + $Value -is [guid]) { + return $Value + } - $paramBlock = $Handler.Ast.ParamBlock - if ($null -eq $paramBlock) { - return $set + # Hashtable / IDictionary -> clone recursively. + if ($Value -is [System.Collections.IDictionary]) { + $copy = @{} + foreach ($k in $Value.Keys) { + $copy[$k] = Copy-IdleDataObject -Value $Value[$k] } + return $copy + } - foreach ($p in $paramBlock.Parameters) { - # Parameter name is stored as '$name' in the AST; we normalize to 'name' - $name = $p.Name.VariablePath.UserPath - if (-not [string]::IsNullOrWhiteSpace($name)) { - [void]$set.Add([string]$name) - } + # Arrays / enumerables -> clone recursively. + if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { + $items = @() + foreach ($item in $Value) { + $items += Copy-IdleDataObject -Value $item } - - return $set + return $items } - $meta = $Handler | Get-Command | Select-Object -ExpandProperty Parameters - foreach ($k in $meta.Keys) { - [void]$set.Add([string]$k) + # PSCustomObject and other objects -> shallow map of public properties (data-only). + $props = $Value.PSObject.Properties | + Where-Object { $_.MemberType -eq 'NoteProperty' -or $_.MemberType -eq 'Property' } + + if ($null -ne $props -and @($props).Count -gt 0) { + $copy = @{} + foreach ($p in $props) { + $copy[$p.Name] = Copy-IdleDataObject -Value $p.Value + } + return [pscustomobject]$copy } - return $set + # Fallback: stable string representation (avoid leaking runtime handles). + return [string]$Value } - function Resolve-IdleStepHandler { + function Normalize-IdleRequiredCapabilities { + <# + .SYNOPSIS + Normalizes the optional RequiresCapabilities key from a workflow step. + + .DESCRIPTION + Supported shapes: + - missing / $null -> empty list + - string -> single capability + - array/enumerable of strings -> list of capabilities + + The output is a stable, sorted, unique string array. + #> [CmdletBinding()] param( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string] $StepType, + [Parameter()] + [AllowNull()] + [object] $Value, [Parameter(Mandatory)] - [ValidateNotNull()] - [object] $StepRegistry + [ValidateNotNullOrEmpty()] + [string] $StepName ) - # Current shape: hashtable mapping step type -> function name (string) - if ($StepRegistry -is [hashtable]) { + if ($null -eq $Value) { + return @() + } - if (-not $StepRegistry.ContainsKey($StepType)) { - throw [System.ArgumentException]::new( - "No step handler registered for step type '$StepType'.", - 'Providers' - ) + $items = @() + + if ($Value -is [string]) { + $items = @($Value) + } + elseif ($Value -is [System.Collections.IEnumerable]) { + foreach ($v in $Value) { + $items += $v } + } + else { + throw [System.ArgumentException]::new( + ("Workflow step '{0}' has invalid RequiresCapabilities value. Expected string or string array." -f $StepName), + 'Workflow' + ) + } - $handler = $StepRegistry[$StepType] - if ($handler -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$handler)) { + $normalized = @() + foreach ($c in $items) { + if ($null -eq $c) { + continue + } + + $s = ([string]$c).Trim() + if ([string]::IsNullOrWhiteSpace($s)) { + continue + } + + # Keep convention aligned with Get-IdleProviderCapabilities: + # - dot-separated segments + # - no whitespace + # - starts with a letter + if ($s -notmatch '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9]+)+$') { throw [System.ArgumentException]::new( - "Step handler for step type '$StepType' must be a non-empty string (function name).", - 'Providers' + ("Workflow step '{0}' declares invalid capability '{1}'. Expected dot-separated segments like 'Identity.Read'." -f $StepName, $s), + 'Workflow' ) } - return ([string]$handler).Trim() - } - - # Backward-compatible shape: registry object with GetStep(string) method. - if ($StepRegistry.PSObject.Methods.Name -contains 'GetStep') { - return $StepRegistry.GetStep($StepType) + $normalized += $s } - throw [System.ArgumentException]::new( - 'Step registry must be a hashtable mapping Step.Type to a handler function name (string).', - 'Providers' - ) + return @($normalized | Sort-Object -Unique) } - $events = [System.Collections.Generic.List[object]]::new() + function Get-IdleProvidersFromMap { + <# + .SYNOPSIS + Extracts provider instances from the -Providers argument. + + .DESCRIPTION + Supported shapes: + - $null -> no providers + - hashtable -> iterate values, ignoring known non-provider keys like 'StepRegistry' + - PSCustomObject -> read public properties as provider entries + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) - # Resolve request/correlation/actor early because New-IdleEventSink requires CorrelationId. - $planPropNames = @($Plan.PSObject.Properties.Name) + if ($null -eq $Providers) { + return @() + } - $request = if ($planPropNames -contains 'Request') { $Plan.Request } else { $null } - $requestPropNames = if ($null -ne $request) { @($request.PSObject.Properties.Name) } else { @() } + $result = @() - $corr = if ($null -ne $request -and $requestPropNames -contains 'CorrelationId') { - $request.CorrelationId - } - else { - if ($planPropNames -contains 'CorrelationId') { $Plan.CorrelationId } else { $null } - } + if ($Providers -is [hashtable]) { + foreach ($k in $Providers.Keys) { + if ([string]$k -eq 'StepRegistry') { + continue + } - $actor = if ($null -ne $request -and $requestPropNames -contains 'Actor') { - $request.Actor - } - else { - if ($planPropNames -contains 'Actor') { $Plan.Actor } else { $null } - } + $v = $Providers[$k] + if ($null -ne $v) { + $result += $v + } + } - # Optional OnFailureSteps are planned but only executed when the run fails. - $onFailureSteps = if ($planPropNames -contains 'OnFailureSteps' -and $null -ne $Plan.OnFailureSteps) { - @($Plan.OnFailureSteps) - } - else { - @() - } + return $result + } - $onFailureStepResults = @() - $onFailureStatus = 'NotRun' + $props = @($Providers.PSObject.Properties) + foreach ($p in $props) { + if ($p.MemberType -ne 'NoteProperty' -and $p.MemberType -ne 'Property') { + continue + } - # 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 + if ([string]$p.Name -eq 'StepRegistry') { + continue + } - # StepRegistry is constructed via helper to ensure built-in steps and host-provided steps can co-exist. - $stepRegistry = Get-IdleStepRegistry -Providers $Providers + if ($null -ne $p.Value) { + $result += $p.Value + } + } - $context = [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionContext' - Plan = $Plan - Providers = $Providers - EventSink = $engineEventSink + return $result } - $context.EventSink.WriteEvent('RunStarted', "Plan execution started (correlationId: $corr).", $null, @{ - CorrelationId = $corr - Actor = $actor - StepCount = @($Plan.Steps).Count - OnFailureStepCount = @($onFailureSteps).Count - }) + function Get-IdleAvailableCapabilities { + <# + .SYNOPSIS + Builds a stable set of capabilities available from the provided providers. - $failed = $false - $stepResults = @() + .DESCRIPTION + Capabilities are discovered from each provider via Get-IdleProviderCapabilities. + During the migration phase we allow minimal inference to avoid breaking existing demos/tests. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Providers + ) - $i = 0 - foreach ($step in $Plan.Steps) { + $all = @() - if ($null -eq $step) { - continue + foreach ($p in @(Get-IdleProvidersFromMap -Providers $Providers)) { + $all += @(Get-IdleProviderCapabilities -Provider $p -AllowInference) } - $stepPropNames = @($step.PSObject.Properties.Name) - - $stepName = if ($stepPropNames -contains 'Name') { $step.Name } else { $null } - $stepType = if ($stepPropNames -contains 'Type') { $step.Type } else { $null } - $stepWith = if ($stepPropNames -contains 'With') { $step.With } else { $null } - $stepStatus = if ($stepPropNames -contains 'Status') { [string]$step.Status } else { '' } + return @($all | Sort-Object -Unique) + } - # Conditions are evaluated during planning and represented as Step.Status. - if ($stepStatus -eq 'NotApplicable') { + function Assert-IdlePlanCapabilitiesSatisfied { + <# + .SYNOPSIS + Validates that all required step capabilities are available. - $stepResults += [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = $stepType - Status = 'NotApplicable' - Attempts = 1 - } + .DESCRIPTION + Fail-fast validation executed during planning. + If one or more capabilities are missing, an ArgumentException is thrown with a + deterministic error message listing missing capabilities and affected steps. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object[]] $Steps, - $context.EventSink.WriteEvent('StepNotApplicable', "Step '$stepName' not applicable (condition not met).", $stepName, @{ - StepType = $stepType - Index = $i - }) + [Parameter()] + [AllowNull()] + [object] $Providers + ) - $i++ - continue + if ($null -eq $Steps -or @($Steps).Count -eq 0) { + return } - $context.EventSink.WriteEvent('StepStarted', "Step '$stepName' started.", $stepName, @{ - StepType = $stepType - Index = $i - }) - - try { - $impl = Resolve-IdleStepHandler -StepType ([string]$stepType) -StepRegistry $stepRegistry + $required = @() + $requiredByStep = @{} - $supportedParams = Get-IdleCommandParameterNames -Handler $impl + foreach ($s in @($Steps)) { + $stepName = if ($s.PSObject.Properties.Name -contains 'Name') { [string]$s.Name } else { '' } + $caps = @() - $invokeParams = @{ - Context = $context + if ($s.PSObject.Properties.Name -contains 'RequiresCapabilities') { + $caps = @($s.RequiresCapabilities) } - if ($null -ne $stepWith -and $supportedParams.Contains('With')) { - $invokeParams.With = $stepWith + if (@($caps).Count -gt 0) { + $required += $caps + $requiredByStep[$stepName] = @($caps) } + } - if ($supportedParams.Contains('Step')) { - $invokeParams.Step = $step - } + $required = @($required | Sort-Object -Unique) + if (@($required).Count -eq 0) { + return + } - # Safe-by-default transient retries: - # - Only retries if the thrown exception is explicitly marked transient. - # - Emits 'StepRetrying' events and uses deterministic jitter/backoff. - $retrySeed = "$corr|$stepType|$stepName|$i" - $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -EventSink $context.EventSink -StepName ([string]$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 - } + $available = @(Get-IdleAvailableCapabilities -Providers $Providers) + + $missing = @() + foreach ($c in $required) { + if ($available -notcontains $c) { + $missing += $c } - 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 + } + + $missing = @($missing | Sort-Object -Unique) + if (@($missing).Count -eq 0) { + return + } + + $affectedSteps = @() + foreach ($k in $requiredByStep.Keys) { + $caps = @($requiredByStep[$k]) + foreach ($m in $missing) { + if ($caps -contains $m) { + $affectedSteps += $k + break } } + } - $stepResults += $result - - if ($result.Status -eq 'Failed') { - $failed = $true + $affectedSteps = @($affectedSteps | Sort-Object -Unique) - $context.EventSink.WriteEvent('StepFailed', "Step '$stepName' failed.", $stepName, @{ - StepType = $stepType - Index = $i - Error = $result.Error - }) + $msg = @() + $msg += "Plan cannot be built because required provider capabilities are missing." + $msg += ("MissingCapabilities: {0}" -f ([string]::Join(', ', @($missing)))) + $msg += ("AffectedSteps: {0}" -f ([string]::Join(', ', @($affectedSteps)))) + $msg += ("AvailableCapabilities: {0}" -f ([string]::Join(', ', @($available)))) - break - } + throw [System.ArgumentException]::new(([string]::Join(' ', $msg)), 'Providers') + } - $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 - } + function Test-IdleWorkflowStepKey { + <# + .SYNOPSIS + Checks whether a workflow step contains a given key. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, - $context.EventSink.WriteEvent('StepFailed', "Step '$stepName' failed.", $stepName, @{ - StepType = $stepType - Index = $i - Error = $err.Exception.Message - }) + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Key + ) - break + if ($Step -is [System.Collections.IDictionary]) { + return $Step.ContainsKey($Key) } - $i++ + return ($Step.PSObject.Properties.Name -contains $Key) } - # Issue #12: - # If the primary run fails, execute OnFailureSteps with best effort. - # - No transient retries in this phase. - # - Failures are recorded, but execution continues for remaining OnFailureSteps. - if ($failed -and @($onFailureSteps).Count -gt 0) { + function Get-IdleWorkflowStepValue { + <# + .SYNOPSIS + Gets a value from a workflow step by key. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Step, - $context.EventSink.WriteEvent('OnFailureStarted', 'OnFailure execution started.', $null, @{ - CorrelationId = $corr - Actor = $actor - StepCount = @($onFailureSteps).Count - }) + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Key + ) - $onFailureFailed = $false + if ($Step -is [System.Collections.IDictionary]) { + return $Step[$Key] + } - $j = 0 - foreach ($step in $onFailureSteps) { + return $Step.PSObject.Properties[$Key].Value + } - if ($null -eq $step) { - $j++ - continue - } + function Normalize-IdleWorkflowSteps { + <# + .SYNOPSIS + Normalizes workflow steps into IdLE.PlanStep objects. - $stepPropNames = @($step.PSObject.Properties.Name) + .DESCRIPTION + Evaluates Condition during planning and sets Status = Planned / NotApplicable. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object[]] $WorkflowSteps, - $stepName = if ($stepPropNames -contains 'Name') { $step.Name } else { $null } - $stepType = if ($stepPropNames -contains 'Type') { $step.Type } else { $null } - $stepWith = if ($stepPropNames -contains 'With') { $step.With } else { $null } - $stepStatus = if ($stepPropNames -contains 'Status') { [string]$step.Status } else { '' } + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $PlanningContext + ) - # Conditions are evaluated during planning and represented as Step.Status. - if ($stepStatus -eq 'NotApplicable') { + if ($null -eq $WorkflowSteps -or @($WorkflowSteps).Count -eq 0) { + return @() + } - $onFailureStepResults += [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = $stepType - Status = 'NotApplicable' - Attempts = 1 - } + $normalizedSteps = @() - $context.EventSink.WriteEvent('OnFailureStepNotApplicable', "OnFailure step '$stepName' not applicable (condition not met).", $stepName, @{ - StepType = $stepType - Index = $j - }) + foreach ($s in @($WorkflowSteps)) { + $stepName = if (Test-IdleWorkflowStepKey -Step $s -Key 'Name') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Name') + } + else { + '' + } - $j++ - continue + if ([string]::IsNullOrWhiteSpace($stepName)) { + throw [System.ArgumentException]::new('Workflow step is missing required key "Name".', 'Workflow') } - $context.EventSink.WriteEvent('OnFailureStepStarted', "OnFailure step '$stepName' started.", $stepName, @{ - StepType = $stepType - Index = $j - }) + $stepType = if (Test-IdleWorkflowStepKey -Step $s -Key 'Type') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Type') + } + else { + '' + } - try { - $impl = Resolve-IdleStepHandler -StepType ([string]$stepType) -StepRegistry $stepRegistry - $supportedParams = Get-IdleCommandParameterNames -Handler $impl + if ([string]::IsNullOrWhiteSpace($stepType)) { + throw [System.ArgumentException]::new(("Workflow step '{0}' is missing required key 'Type'." -f $stepName), 'Workflow') + } - $invokeParams = @{ - Context = $context - } + if (Test-IdleWorkflowStepKey -Step $s -Key 'When') { + throw [System.ArgumentException]::new( + ("Workflow step '{0}' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition." -f $stepName), + 'Workflow' + ) + } - if ($null -ne $stepWith -and $supportedParams.Contains('With')) { - $invokeParams.With = $stepWith - } + $condition = if (Test-IdleWorkflowStepKey -Step $s -Key 'Condition') { + Get-IdleWorkflowStepValue -Step $s -Key 'Condition' + } + else { + $null + } - if ($supportedParams.Contains('Step')) { - $invokeParams.Step = $step + $status = 'Planned' + if ($null -ne $condition) { + $schemaErrors = Test-IdleConditionSchema -Condition $condition -StepName $stepName + if (@($schemaErrors).Count -gt 0) { + throw [System.ArgumentException]::new( + ("Invalid Condition on step '{0}': {1}" -f $stepName, ([string]::Join(' ', @($schemaErrors)))), + 'Workflow' + ) } - # Best effort: no transient retries in the OnFailure phase. - $result = & $impl @invokeParams - - if ($null -eq $result) { - $result = [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = $stepType - Status = 'Completed' - Attempts = 1 - } - } - 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 1 -Force - } + $isApplicable = Test-IdleCondition -Condition $condition -Context $PlanningContext + if (-not $isApplicable) { + $status = 'NotApplicable' } + } - $onFailureStepResults += $result - - if ($result.Status -eq 'Failed') { - $onFailureFailed = $true + $requiresCaps = @() + if (Test-IdleWorkflowStepKey -Step $s -Key 'RequiresCapabilities') { + $requiresCaps = Normalize-IdleRequiredCapabilities -Value (Get-IdleWorkflowStepValue -Step $s -Key 'RequiresCapabilities') -StepName $stepName + } - $context.EventSink.WriteEvent('OnFailureStepFailed', "OnFailure step '$stepName' failed.", $stepName, @{ - StepType = $stepType - Index = $j - Error = $result.Error - }) - } - else { - $context.EventSink.WriteEvent('OnFailureStepCompleted', "OnFailure step '$stepName' completed.", $stepName, @{ - StepType = $stepType - Index = $j - }) - } + $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { + [string](Get-IdleWorkflowStepValue -Step $s -Key 'Description') + } + else { + '' } - catch { - $onFailureFailed = $true - $err = $_ - - $onFailureStepResults += [pscustomobject]@{ - PSTypeName = 'IdLE.StepResult' - Name = $stepName - Type = $stepType - Status = 'Failed' - Error = $err.Exception.Message - Attempts = 1 - } - $context.EventSink.WriteEvent('OnFailureStepFailed', "OnFailure step '$stepName' failed.", $stepName, @{ - StepType = $stepType - Index = $j - Error = $err.Exception.Message - }) + $with = if (Test-IdleWorkflowStepKey -Step $s -Key 'With') { + Copy-IdleDataObject -Value (Get-IdleWorkflowStepValue -Step $s -Key 'With') + } + else { + @{} } - $j++ + $normalizedSteps += [pscustomobject]@{ + PSTypeName = 'IdLE.PlanStep' + Name = $stepName + Type = $stepType + Description = $description + Condition = Copy-IdleDataObject -Value $condition + With = $with + RequiresCapabilities = $requiresCaps + Status = $status + } } - $onFailureStatus = if ($onFailureFailed) { 'PartiallyFailed' } else { 'Completed' } - - $context.EventSink.WriteEvent('OnFailureCompleted', "OnFailure execution finished (status: $onFailureStatus).", $null, @{ - Status = $onFailureStatus - StepCount = @($onFailureSteps).Count - }) + return $normalizedSteps } - $runStatus = if ($failed) { 'Failed' } else { 'Completed' } - - $context.EventSink.WriteEvent('RunCompleted', "Plan execution finished (status: $runStatus).", $null, @{ - Status = $runStatus - StepCount = @($Plan.Steps).Count - OnFailureStatus = $onFailureStatus - OnFailureStepCount = @($onFailureSteps).Count - }) + # Ensure required request properties exist without hard-typing the request class. + $reqProps = $Request.PSObject.Properties.Name + if ($reqProps -notcontains 'LifecycleEvent') { + throw [System.ArgumentException]::new("Request object must contain property 'LifecycleEvent'.", 'Request') + } + if ($reqProps -notcontains 'CorrelationId') { + throw [System.ArgumentException]::new("Request object must contain property 'CorrelationId'.", 'Request') + } - # Issue #48: - # Redact provider configuration/state at the output boundary (execution result). - $redactedProviders = if ($null -ne $Providers) { - Copy-IdleRedactedObject -Value $Providers + # Create a data-only snapshot of the incoming request for deterministic exports. + $requestSnapshot = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequestSnapshot' + LifecycleEvent = ConvertTo-NullIfEmptyString -Value ([string]$Request.LifecycleEvent) + CorrelationId = ConvertTo-NullIfEmptyString -Value ([string]$Request.CorrelationId) + Actor = if ($reqProps -contains 'Actor') { ConvertTo-NullIfEmptyString -Value ([string]$Request.Actor) } else { $null } + IdentityKeys = if ($reqProps -contains 'IdentityKeys') { Copy-IdleDataObject -Value $Request.IdentityKeys } else { $null } + DesiredState = if ($reqProps -contains 'DesiredState') { Copy-IdleDataObject -Value $Request.DesiredState } else { $null } + Changes = if ($reqProps -contains 'Changes') { Copy-IdleDataObject -Value $Request.Changes } else { $null } } - else { - $null + + # Validate workflow and ensure it matches the request's LifecycleEvent. + $workflow = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request + + # Create the plan object (planning artifact). + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + WorkflowName = [string]$workflow.Name + LifecycleEvent = [string]$workflow.LifecycleEvent + CorrelationId = [string]$requestSnapshot.CorrelationId + Request = $requestSnapshot + Actor = $requestSnapshot.Actor + CreatedUtc = [DateTime]::UtcNow + + Steps = @() + OnFailureSteps = @() + + Actions = @() + Warnings = @() + Providers = $Providers } - return [pscustomobject]@{ - PSTypeName = 'IdLE.ExecutionResult' - Status = $runStatus - CorrelationId = $corr - Actor = $actor - Steps = $stepResults - OnFailure = [pscustomobject]@{ - PSTypeName = 'IdLE.OnFailureExecutionResult' - Status = $onFailureStatus - Steps = $onFailureStepResults - } - Events = $events - Providers = $redactedProviders + # Build a planning context for condition evaluation. + $planningContext = [pscustomobject]@{ + Plan = $plan + Request = $Request + Workflow = $workflow } + + # Normalize primary and OnFailure steps. + $plan.Steps = Normalize-IdleWorkflowSteps -WorkflowSteps @($workflow.Steps) -PlanningContext $planningContext + $plan.OnFailureSteps = Normalize-IdleWorkflowSteps -WorkflowSteps @($workflow.OnFailureSteps) -PlanningContext $planningContext + + # Fail-fast capability validation (includes OnFailureSteps). + $allStepsForCapabilities = @() + $allStepsForCapabilities += @($plan.Steps) + $allStepsForCapabilities += @($plan.OnFailureSteps) + + Assert-IdlePlanCapabilitiesSatisfied -Steps $allStepsForCapabilities -Providers $Providers + + return $plan } From 96ec97f5db8ccc691da791fc938ce58eb20b6af5 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:17:44 +0100 Subject: [PATCH 13/21] fix(core): ensure Normalize-IdleWorkflowSteps always returns arrays --- .../Public/Invoke-IdlePlanObject.ps1 | 243 ++++++++++++++---- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 234 +++++++++++------ 2 files changed, 343 insertions(+), 134 deletions(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 5b38cc64..05b5bc6b 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -14,10 +14,10 @@ function Invoke-IdlePlanObject { Plan object created by New-IdlePlanObject. .PARAMETER Providers - Provider registry/collection (may be $null). + Provider registry/collection passed through to execution. .PARAMETER EventSink - Optional external event sink provided by the host. Must be an object with WriteEvent(event) method. + Optional external event sink object. Must provide a WriteEvent(event) method. .OUTPUTS PSCustomObject (PSTypeName: IdLE.ExecutionResult) @@ -30,16 +30,13 @@ function Invoke-IdlePlanObject { [Parameter()] [AllowNull()] - [hashtable] $Providers, + [object] $Providers, [Parameter()] [AllowNull()] [object] $EventSink ) - Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' - Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' - function Get-IdleCommandParameterNames { [CmdletBinding()] param( @@ -59,31 +56,21 @@ function Invoke-IdlePlanObject { } foreach ($p in $paramBlock.Parameters) { - # Parameter name is stored as '$name' in the AST; we normalize to 'name' - $name = $p.Name.VariablePath.UserPath - if (-not [string]::IsNullOrWhiteSpace($name)) { - [void]$set.Add($name) - } + # Parameter name is stored without the leading '$' + $null = $set.Add([string]$p.Name.VariablePath.UserPath) } return $set } - # Command name or CommandInfo - $command = $null - if ($Handler -is [System.Management.Automation.CommandInfo]) { - $command = $Handler - } - else { - # Most commonly, registry returns a function name (string) - $command = Get-Command -Name $Handler -ErrorAction Stop - } - - foreach ($kv in $command.Parameters.GetEnumerator()) { - [void]$set.Add($kv.Key) + foreach ($n in $Handler.Parameters.Keys) { + $null = $set.Add([string]$n) + } + return $set } + # Unknown handler shape: return an empty set. return $set } @@ -99,42 +86,35 @@ function Invoke-IdlePlanObject { [object] $StepRegistry ) - # Get-IdleStepRegistry currently returns a hashtable that maps Step.Type -> handler function name (string). - # We keep this resolver isolated so that internal representation changes don't leak into the engine loop. + $handlerName = $null - if ($StepRegistry -is [hashtable]) { - if (-not $StepRegistry.ContainsKey($StepType)) { - throw [System.ArgumentException]::new( - "No step handler registered for step type '$StepType'.", - 'Providers' - ) + if ($StepRegistry -is [System.Collections.IDictionary]) { + if ($StepRegistry.Contains($StepType)) { + $handlerName = $StepRegistry[$StepType] } - - $handler = $StepRegistry[$StepType] - if ($handler -isnot [string] -or [string]::IsNullOrWhiteSpace([string]$handler)) { - throw [System.ArgumentException]::new( - "Step handler for step type '$StepType' must be a non-empty string (function name).", - 'Providers' - ) + } + else { + if ($StepRegistry.PSObject.Properties.Name -contains $StepType) { + $handlerName = $StepRegistry.$StepType } + } - return ([string]$handler).Trim() + if ($null -eq $handlerName -or [string]::IsNullOrWhiteSpace([string]$handlerName)) { + throw [System.ArgumentException]::new("No step handler registered for step type '$StepType'.", 'Providers') } - # Backward-compatible shape: registry object with GetStep(string) method. - if ($StepRegistry.PSObject.Methods.Name -contains 'GetStep') { - return $StepRegistry.GetStep($StepType) + # Reject ScriptBlock handlers (secure default). + if ($handlerName -is [scriptblock]) { + throw [System.ArgumentException]::new( + "Step registry handler for '$StepType' must be a function name (string), not a ScriptBlock.", + 'Providers' + ) } - throw [System.ArgumentException]::new( - 'Step registry must be a hashtable mapping Step.Type to a handler function name (string).', - 'Providers' - ) + $cmd = Get-Command -Name ([string]$handlerName) -CommandType Function -ErrorAction Stop + return $cmd } - $events = [System.Collections.Generic.List[object]]::new() - - # Resolve request/correlation/actor early because New-IdleEventSink requires CorrelationId. $planPropNames = @($Plan.PSObject.Properties.Name) $request = if ($planPropNames -contains 'Request') { $Plan.Request } else { $null } @@ -154,6 +134,8 @@ function Invoke-IdlePlanObject { 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 @@ -167,7 +149,7 @@ function Invoke-IdlePlanObject { EventSink = $engineEventSink } - $context.EventSink.WriteEvent('RunStarted', "Plan execution started (correlationId: $corr).", $null, @{ + $context.EventSink.WriteEvent('RunStarted', 'Plan execution started.', $null, @{ CorrelationId = $corr Actor = $actor StepCount = @($Plan.Steps).Count @@ -185,7 +167,7 @@ function Invoke-IdlePlanObject { $stepPropNames = @($step.PSObject.Properties.Name) - $stepName = if ($stepPropNames -contains 'Name') { $step.Name } else { $null } + $stepName = if ($stepPropNames -contains 'Name') { [string]$step.Name } else { '' } $stepType = if ($stepPropNames -contains 'Type') { $step.Type } else { $null } $stepWith = if ($stepPropNames -contains 'With') { $step.With } else { $null } $stepStatus = if ($stepPropNames -contains 'Status') { [string]$step.Status } else { '' } @@ -236,7 +218,7 @@ function Invoke-IdlePlanObject { # - Only retries if the thrown exception is explicitly marked transient. # - Emits 'StepRetrying' events and uses deterministic jitter/backoff. $retrySeed = "$corr|$stepType|$stepName|$i" - $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -EventSink $context.EventSink -StepName ([string]$stepName) -OperationName 'StepExecution' -DeterministicSeed $retrySeed + $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -EventSink $context.EventSink -StepName $stepName -OperationName 'StepExecution' -DeterministicSeed $retrySeed $result = $retry.Value $attempts = [int]$retry.Attempts @@ -303,6 +285,162 @@ function Invoke-IdlePlanObject { $runStatus = if ($failed) { 'Failed' } else { 'Completed' } + # Public result contract: the OnFailure section is always present. + $onFailure = [pscustomobject]@{ + PSTypeName = 'IdLE.OnFailureExecutionResult' + Status = 'NotRun' + Steps = @() + } + + $planOnFailureSteps = @() + if ($planPropNames -contains 'OnFailureSteps') { + # Treat nulls as empty deterministically. + $planOnFailureSteps = @($Plan.OnFailureSteps) | Where-Object { $null -ne $_ } + } + + if ($failed -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 + } + + $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 = @{ + 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. + $retrySeed = "$corr|OnFailure|$ofType|$ofName|$j" + $retry = Invoke-IdleWithRetry -Operation { & $impl @invokeParams } -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 @@ -323,6 +461,7 @@ function Invoke-IdlePlanObject { CorrelationId = $corr Actor = $actor Steps = $stepResults + OnFailure = $onFailure Events = $events Providers = $redactedProviders } diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index d10f4f94..d5f7b3ab 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -45,14 +45,14 @@ function New-IdlePlanObject { param( [Parameter()] [AllowNull()] - [object] $Value + [string] $Value ) if ($null -eq $Value) { return $null } - if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } @@ -60,6 +60,23 @@ function New-IdlePlanObject { } function Copy-IdleDataObject { + <# + .SYNOPSIS + Creates a deep-ish, data-only copy of an object. + + .DESCRIPTION + This helper is used to snapshot the request input so that the plan can be exported + deterministically, without retaining references to the original live object. + + NOTE: + This is intentionally conservative and only supports data-like objects: + - Hashtable / OrderedDictionary + - PSCustomObject / NoteProperties + - Arrays/lists + - Primitive types + + ScriptBlocks and other executable objects are rejected by upstream validation. + #> [CmdletBinding()] param( [Parameter()] @@ -67,54 +84,77 @@ function New-IdlePlanObject { [object] $Value ) - if ($null -eq $Value) { - return $null - } - - # Primitive / immutable-ish types can be returned as-is. - if ($Value -is [string] -or - $Value -is [int] -or - $Value -is [long] -or - $Value -is [double] -or - $Value -is [decimal] -or - $Value -is [bool] -or - $Value -is [datetime] -or - $Value -is [guid]) { - return $Value - } + if ($null -eq $Value) { return $null } - # Hashtable / IDictionary -> clone recursively. if ($Value -is [System.Collections.IDictionary]) { - $copy = @{} + $copy = [ordered]@{} foreach ($k in $Value.Keys) { $copy[$k] = Copy-IdleDataObject -Value $Value[$k] } return $copy } - # Arrays / enumerables -> clone recursively. - if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { - $items = @() + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + $arr = @() foreach ($item in $Value) { - $items += Copy-IdleDataObject -Value $item + $arr += Copy-IdleDataObject -Value $item } - return $items + return $arr } - # PSCustomObject and other objects -> shallow map of public properties (data-only). - $props = $Value.PSObject.Properties | - Where-Object { $_.MemberType -eq 'NoteProperty' -or $_.MemberType -eq 'Property' } - + $props = @($Value.PSObject.Properties | Where-Object MemberType -in @('NoteProperty', 'Property')) if ($null -ne $props -and @($props).Count -gt 0) { - $copy = @{} + $o = [ordered]@{} foreach ($p in $props) { - $copy[$p.Name] = Copy-IdleDataObject -Value $p.Value + $o[$p.Name] = Copy-IdleDataObject -Value $p.Value + } + return [pscustomobject]$o + } + + return $Value + } + + function Get-IdleOptionalPropertyValue { + <# + .SYNOPSIS + Safely reads an optional property from an object. + + .DESCRIPTION + Works with: + - IDictionary (hashtables / ordered dictionaries) + - PSCustomObject / objects with note properties + + Returns $null when the property does not exist. + Uses Get-Member to avoid PropertyNotFoundException in strict mode. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object] $Object, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name + ) + + if ($null -eq $Object) { + return $null + } + + if ($Object -is [System.Collections.IDictionary]) { + if ($Object.ContainsKey($Name)) { + return $Object[$Name] } - return [pscustomobject]$copy + return $null } - # Fallback: stable string representation (avoid leaking runtime handles). - return [string]$Value + $m = $Object | Get-Member -Name $Name -MemberType NoteProperty,Property -ErrorAction SilentlyContinue + if ($null -eq $m) { + return $null + } + + return $Object.$Name } function Normalize-IdleRequiredCapabilities { @@ -196,10 +236,11 @@ function New-IdlePlanObject { Extracts provider instances from the -Providers argument. .DESCRIPTION - Supported shapes: - - $null -> no providers - - hashtable -> iterate values, ignoring known non-provider keys like 'StepRegistry' - - PSCustomObject -> read public properties as provider entries + Supports both: + - hashtable map: @{ Name = ; ... } + - array/list: @( , ... ) + + Returns an array of provider objects. #> [CmdletBinding()] param( @@ -212,49 +253,60 @@ function New-IdlePlanObject { return @() } - $result = @() - - if ($Providers -is [hashtable]) { + if ($Providers -is [System.Collections.IDictionary]) { + $items = @() foreach ($k in $Providers.Keys) { - if ([string]$k -eq 'StepRegistry') { - continue - } - - $v = $Providers[$k] - if ($null -ne $v) { - $result += $v - } + $items += $Providers[$k] } - - return $result + return @($items) } - $props = @($Providers.PSObject.Properties) - foreach ($p in $props) { - if ($p.MemberType -ne 'NoteProperty' -and $p.MemberType -ne 'Property') { - continue + if ($Providers -is [System.Collections.IEnumerable] -and $Providers -isnot [string]) { + $items = @() + foreach ($p in $Providers) { + $items += $p } + return @($items) + } - if ([string]$p.Name -eq 'StepRegistry') { - continue - } + return @($Providers) + } + + function Get-IdleProviderCapabilities { + <# + .SYNOPSIS + Gets the capability list advertised by a provider. - if ($null -ne $p.Value) { - $result += $p.Value + .DESCRIPTION + Providers are expected to expose a GetCapabilities() method. + If not present, the provider is treated as advertising no capabilities. + #> + [CmdletBinding()] + param( + [Parameter()] + [AllowNull()] + [object] $Provider + ) + + if ($null -eq $Provider) { + return @() + } + + if ($Provider.PSObject.Methods.Name -contains 'GetCapabilities') { + $caps = $Provider.GetCapabilities() + if ($null -eq $caps) { + return @() } + return @($caps | Where-Object { $null -ne $_ } | ForEach-Object { ([string]$_).Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) } - return $result + return @() } function Get-IdleAvailableCapabilities { <# .SYNOPSIS - Builds a stable set of capabilities available from the provided providers. - - .DESCRIPTION - Capabilities are discovered from each provider via Get-IdleProviderCapabilities. - During the migration phase we allow minimal inference to avoid breaking existing demos/tests. + Aggregates capabilities from all providers. #> [CmdletBinding()] param( @@ -263,13 +315,14 @@ function New-IdlePlanObject { [object] $Providers ) - $all = @() + $providerInstances = @(Get-IdleProvidersFromMap -Providers $Providers) - foreach ($p in @(Get-IdleProvidersFromMap -Providers $Providers)) { - $all += @(Get-IdleProviderCapabilities -Provider $p -AllowInference) + $caps = @() + foreach ($p in $providerInstances) { + $caps += @(Get-IdleProviderCapabilities -Provider $p) } - return @($all | Sort-Object -Unique) + return @($caps | Sort-Object -Unique) } function Assert-IdlePlanCapabilitiesSatisfied { @@ -298,16 +351,21 @@ function New-IdlePlanObject { } $required = @() - $requiredByStep = @{} + $requiredByStep = [ordered]@{} foreach ($s in @($Steps)) { - $stepName = if ($s.PSObject.Properties.Name -contains 'Name') { [string]$s.Name } else { '' } - $caps = @() + if ($null -eq $s) { + continue + } - if ($s.PSObject.Properties.Name -contains 'RequiresCapabilities') { - $caps = @($s.RequiresCapabilities) + $stepName = Get-IdleOptionalPropertyValue -Object $s -Name 'Name' + if ($null -eq $stepName -or [string]::IsNullOrWhiteSpace([string]$stepName)) { + $stepName = '' } + $capsRaw = Get-IdleOptionalPropertyValue -Object $s -Name 'RequiresCapabilities' + $caps = if ($null -eq $capsRaw) { @() } else { @($capsRaw) } + if (@($caps).Count -gt 0) { $required += $caps $requiredByStep[$stepName] = @($caps) @@ -335,9 +393,9 @@ function New-IdlePlanObject { $affectedSteps = @() foreach ($k in $requiredByStep.Keys) { - $caps = @($requiredByStep[$k]) + $capsForStep = @($requiredByStep[$k]) foreach ($m in $missing) { - if ($caps -contains $m) { + if ($capsForStep -contains $m) { $affectedSteps += $k break } @@ -375,7 +433,8 @@ function New-IdlePlanObject { return $Step.ContainsKey($Key) } - return ($Step.PSObject.Properties.Name -contains $Key) + $m = $Step | Get-Member -Name $Key -MemberType NoteProperty,Property -ErrorAction SilentlyContinue + return ($null -ne $m) } function Get-IdleWorkflowStepValue { @@ -398,7 +457,7 @@ function New-IdlePlanObject { return $Step[$Key] } - return $Step.PSObject.Properties[$Key].Value + return $Step.$Key } function Normalize-IdleWorkflowSteps { @@ -408,10 +467,14 @@ function New-IdlePlanObject { .DESCRIPTION Evaluates Condition during planning and sets Status = Planned / NotApplicable. + + IMPORTANT: + WorkflowSteps is optional and may be null or empty. A workflow is allowed to omit + OnFailureSteps entirely. Therefore we must not mark this parameter as Mandatory. #> [CmdletBinding()] param( - [Parameter(Mandatory)] + [Parameter()] [AllowNull()] [object[]] $WorkflowSteps, @@ -510,7 +573,10 @@ function New-IdlePlanObject { } } - return $normalizedSteps + # IMPORTANT: + # Returning an empty array variable can produce no pipeline output, resulting in $null on assignment. + # Force a stable array output shape. + return @($normalizedSteps) } # Ensure required request properties exist without hard-typing the request class. @@ -561,9 +627,13 @@ function New-IdlePlanObject { Workflow = $workflow } + $workflowOnFailureSteps = Get-IdleOptionalPropertyValue -Object $workflow -Name 'OnFailureSteps' + # Normalize primary and OnFailure steps. - $plan.Steps = Normalize-IdleWorkflowSteps -WorkflowSteps @($workflow.Steps) -PlanningContext $planningContext - $plan.OnFailureSteps = Normalize-IdleWorkflowSteps -WorkflowSteps @($workflow.OnFailureSteps) -PlanningContext $planningContext + # IMPORTANT: + # Normalize-IdleWorkflowSteps may return an empty array that would otherwise collapse to $null on assignment. + $plan.Steps = @(Normalize-IdleWorkflowSteps -WorkflowSteps $workflow.Steps -PlanningContext $planningContext) + $plan.OnFailureSteps = @(Normalize-IdleWorkflowSteps -WorkflowSteps $workflowOnFailureSteps -PlanningContext $planningContext) # Fail-fast capability validation (includes OnFailureSteps). $allStepsForCapabilities = @() From c31c94103704d913d82d901163e9bdb8a23d95b4 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:53:17 +0100 Subject: [PATCH 14/21] fix(core): prevent strings from being serialized as objects in Copy-IdleDataObject The Copy-IdleDataObject function in New-IdlePlanObject was incorrectly converting string values to PSCustomObjects with a Length property when serializing to JSON. This occurred because strings have properties (like Length, Chars, etc.) and the function was checking for properties before checking for primitive types. Added primitive type check before property inspection to ensure strings, integers, booleans, and other immutable types are returned as-is. This fixes the Export-IdlePlan test which was expecting: "userId": "jdoe" But was getting: "userId": { "Length": 4 } Fixes Export-IdlePlan.Tests.ps1 and New-IdlePlan.Tests.ps1 failures. --- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index d5f7b3ab..10132557 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -86,6 +86,19 @@ function New-IdlePlanObject { if ($null -eq $Value) { return $null } + # Primitive / immutable types should be returned as-is before property inspection. + # This prevents strings from being converted to PSCustomObject with Length property. + if ($Value -is [string] -or + $Value -is [int] -or + $Value -is [long] -or + $Value -is [double] -or + $Value -is [decimal] -or + $Value -is [bool] -or + $Value -is [datetime] -or + $Value -is [guid]) { + return $Value + } + if ($Value -is [System.Collections.IDictionary]) { $copy = [ordered]@{} foreach ($k in $Value.Keys) { From 19115a0e07687e27c9001bb3c68428684c65e66c Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Fri, 9 Jan 2026 18:57:56 +0100 Subject: [PATCH 15/21] chore(core): use approved verbs for conversion helpers Rename Normalize-IdleRequiredCapabilities -> ConvertTo-IdleRequiredCapabilities and Normalize-IdleWorkflowSteps -> ConvertTo-IdleWorkflowSteps, updating all call sites/comments to align with approved PowerShell verbs. No behavioral changes intended; keeps the planning helpers consistent with verb guidelines. --- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 10132557..3124f1b5 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -170,7 +170,7 @@ function New-IdlePlanObject { return $Object.$Name } - function Normalize-IdleRequiredCapabilities { + function ConvertTo-IdleRequiredCapabilities { <# .SYNOPSIS Normalizes the optional RequiresCapabilities key from a workflow step. @@ -473,7 +473,7 @@ function New-IdlePlanObject { return $Step.$Key } - function Normalize-IdleWorkflowSteps { + function ConvertTo-IdleWorkflowSteps { <# .SYNOPSIS Normalizes workflow steps into IdLE.PlanStep objects. @@ -557,7 +557,7 @@ function New-IdlePlanObject { $requiresCaps = @() if (Test-IdleWorkflowStepKey -Step $s -Key 'RequiresCapabilities') { - $requiresCaps = Normalize-IdleRequiredCapabilities -Value (Get-IdleWorkflowStepValue -Step $s -Key 'RequiresCapabilities') -StepName $stepName + $requiresCaps = ConvertTo-IdleRequiredCapabilities -Value (Get-IdleWorkflowStepValue -Step $s -Key 'RequiresCapabilities') -StepName $stepName } $description = if (Test-IdleWorkflowStepKey -Step $s -Key 'Description') { @@ -644,9 +644,9 @@ function New-IdlePlanObject { # Normalize primary and OnFailure steps. # IMPORTANT: - # Normalize-IdleWorkflowSteps may return an empty array that would otherwise collapse to $null on assignment. - $plan.Steps = @(Normalize-IdleWorkflowSteps -WorkflowSteps $workflow.Steps -PlanningContext $planningContext) - $plan.OnFailureSteps = @(Normalize-IdleWorkflowSteps -WorkflowSteps $workflowOnFailureSteps -PlanningContext $planningContext) + # ConvertTo-IdleWorkflowSteps may return an empty array that would otherwise collapse to $null on assignment. + $plan.Steps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflow.Steps -PlanningContext $planningContext) + $plan.OnFailureSteps = @(ConvertTo-IdleWorkflowSteps -WorkflowSteps $workflowOnFailureSteps -PlanningContext $planningContext) # Fail-fast capability validation (includes OnFailureSteps). $allStepsForCapabilities = @() From 8bda1f4096529ad4f61d902ea17165e46714b0b1 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:29:02 +0100 Subject: [PATCH 16/21] fix(tests): wrap Where-Object results in @() to handle empty collections When Where-Object returns no matches, it returns $null. Accessing .Count on $null fails with PropertyNotFoundException. Wrapping in @() ensures an empty array is created, which has a .Count property. Fixes failing tests: - Invoke-IdlePlan: does not execute OnFailureSteps when run completes successfully - ModuleSurface: Invoke-IdlePlan returns a public execution result that includes an OnFailure section All 107 tests now passing. --- tests/Invoke-IdlePlan.Tests.ps1 | 4 ++-- tests/ModuleSurface.Tests.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index afd81fc5..89a8b4bc 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -360,8 +360,8 @@ Describe 'Invoke-IdlePlan' { $result.OnFailure.Status | Should -Be 'NotRun' @($result.OnFailure.Steps).Count | Should -Be 0 - ($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 - ($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 + @($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 + @($result.Events | Where-Object Type -eq 'Custom').Count | Should -Be 0 } It 'fails planning when a step is missing Type' { diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 index 943f5181..8c05b5be 100644 --- a/tests/ModuleSurface.Tests.ps1 +++ b/tests/ModuleSurface.Tests.ps1 @@ -104,7 +104,7 @@ Describe 'Module manifests and public surface' { @($result.OnFailure.Steps).Count | Should -Be 0 # Successful runs must not emit OnFailure events. - ($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 + @($result.Events | Where-Object Type -like 'OnFailure*').Count | Should -Be 0 } It 'Importing IdLE makes built-in steps available to the engine without exporting them globally' { From 3866c01f63a66107b2af84d95fb2b2bd9b527815 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:30:28 +0100 Subject: [PATCH 17/21] fix(core): ensure Steps arrays in execution results are always typed arrays Wrap step results in @() to ensure they are always arrays, even when empty or containing a single element. This prevents PowerShell from unwrapping single items and ensures consistent array behavior. Changes: - OnFailure.Steps: Use [object[]]@() for initial empty array - OnFailure.Steps: Wrap $onFailureStepResults in @() when populated - ExecutionResult.Steps: Wrap $stepResults in @() This ensures .Count property is always accessible and array behavior is consistent. --- src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 05b5bc6b..0c958580 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -289,7 +289,7 @@ function Invoke-IdlePlanObject { $onFailure = [pscustomobject]@{ PSTypeName = 'IdLE.OnFailureExecutionResult' Status = 'NotRun' - Steps = @() + Steps = [object[]]@() } $planOnFailureSteps = @() @@ -431,7 +431,7 @@ function Invoke-IdlePlanObject { $onFailure = [pscustomobject]@{ PSTypeName = 'IdLE.OnFailureExecutionResult' Status = $onFailureStatus - Steps = $onFailureStepResults + Steps = @($onFailureStepResults) } $context.EventSink.WriteEvent('OnFailureCompleted', "OnFailureSteps finished (status: $onFailureStatus).", $null, @{ @@ -460,7 +460,7 @@ function Invoke-IdlePlanObject { Status = $runStatus CorrelationId = $corr Actor = $actor - Steps = $stepResults + Steps = @($stepResults) OnFailure = $onFailure Events = $events Providers = $redactedProviders From b2b4ed4cebd64173e2b024d6c68b05f6420fbec1 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:40:31 +0100 Subject: [PATCH 18/21] Fix demo script execution failures - Add missing -Providers parameter to New-IdlePlan call in demo script - Change Copy-IdleDataObject to return regular hashtables instead of OrderedDictionary to maintain compatibility with step implementations that expect [hashtable] type Steps correctly use .ContainsKey() which is available on Hashtable but not on OrderedDictionary. The planning layer now ensures steps receive the expected type. --- examples/Invoke-IdleDemo.ps1 | 2 +- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/Invoke-IdleDemo.ps1 b/examples/Invoke-IdleDemo.ps1 index 9d092a02..a76d5e50 100644 --- a/examples/Invoke-IdleDemo.ps1 +++ b/examples/Invoke-IdleDemo.ps1 @@ -238,7 +238,7 @@ foreach ($wf in $selected) { Write-DemoHeader "Plan" $lifecycleEvent = Get-IdleLifecycleEventFromWorkflowName -Name $wf.Name $request = New-IdleLifecycleRequest -LifecycleEvent $lifecycleEvent -Actor 'example-user' - $plan = New-IdlePlan -WorkflowPath $wf.Path -Request $request + $plan = New-IdlePlan -WorkflowPath $wf.Path -Request $request -Providers $providers Write-Host ("Plan created: LifecycleEvent={0} | Steps={1}" -f $lifecycleEvent, ($plan.Steps | Measure-Object).Count) Write-Host "" diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 3124f1b5..05aa98cd 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -100,7 +100,7 @@ function New-IdlePlanObject { } if ($Value -is [System.Collections.IDictionary]) { - $copy = [ordered]@{} + $copy = @{} foreach ($k in $Value.Keys) { $copy[$k] = Copy-IdleDataObject -Value $Value[$k] } From f5f4922b61c0f311aee7e0de3d47f317cd688cb5 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:41:48 +0100 Subject: [PATCH 19/21] changing Event variable to EventRecord as Event is PS internal --- examples/Invoke-IdleDemo.ps1 | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/Invoke-IdleDemo.ps1 b/examples/Invoke-IdleDemo.ps1 index a76d5e50..c24d2f3a 100644 --- a/examples/Invoke-IdleDemo.ps1 +++ b/examples/Invoke-IdleDemo.ps1 @@ -56,7 +56,7 @@ function Write-DemoHeader { } function Format-EventRow { - param([Parameter(Mandatory)][object]$Event) + param([Parameter(Mandatory)][object]$EventRecord) $icons = @{ RunStarted = '🚀' @@ -69,23 +69,23 @@ function Format-EventRow { Debug = '🔎' } - $icon = if ($icons.ContainsKey($Event.Type)) { $icons[$Event.Type] } else { '•' } + $icon = if ($icons.ContainsKey($EventRecord.Type)) { $icons[$EventRecord.Type] } else { '•' } - $time = ([DateTime]$Event.TimestampUtc).ToLocalTime().ToString('HH:mm:ss.fff') - $step = if ([string]::IsNullOrWhiteSpace($Event.StepName)) { '-' } else { [string]$Event.StepName } + $time = ([DateTime]$EventRecord.TimestampUtc).ToLocalTime().ToString('HH:mm:ss.fff') + $step = if ([string]::IsNullOrWhiteSpace($EventRecord.StepName)) { '-' } else { [string]$EventRecord.StepName } - $msg = [string]$Event.Message + $msg = [string]$EventRecord.Message # IMPORTANT: Show error details if the engine attached them. - if ($Event.PSObject.Properties.Name -contains 'Data' -and $Event.Data -is [hashtable]) { - if ($Event.Data.ContainsKey('Error') -and -not [string]::IsNullOrWhiteSpace([string]$Event.Data.Error)) { - $msg = "$msg | ERROR: $([string]$Event.Data.Error)" + if ($EventRecord.PSObject.Properties.Name -contains 'Data' -and $EventRecord.Data -is [hashtable]) { + if ($EventRecord.Data.ContainsKey('Error') -and -not [string]::IsNullOrWhiteSpace([string]$EventRecord.Data.Error)) { + $msg = "$msg | ERROR: $([string]$EventRecord.Data.Error)" } } [pscustomobject]@{ Time = $time - Type = "$icon $($Event.Type)" + Type = "$icon $($EventRecord.Type)" Step = $step Message = $msg } From 57992417885901ec621f28c2d42ccccfff482add Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:51:57 +0100 Subject: [PATCH 20/21] docs: document OnFailureSteps feature (Issue #12) Add comprehensive documentation for OnFailureSteps feature: - docs/usage/workflows.md: Add OnFailureSteps section with examples and best practices - docs/usage/steps.md: Expand error behavior section with OnFailureSteps explanation - docs/reference/configuration.md: Document OnFailureSteps in workflow configuration layer - examples/README.md: List new joiner-with-onfailure.psd1 example - examples/workflows/joiner-with-onfailure.psd1: New example demonstrating OnFailureSteps OnFailureSteps are executed in best-effort mode when primary steps fail: - Each OnFailure step is attempted regardless of previous failures - OnFailure failures don't stop remaining OnFailure steps - Overall status remains 'Failed' even if all OnFailure steps succeed Result structure includes separate OnFailure section with Status and Steps. --- docs/reference/configuration.md | 5 ++ docs/usage/steps.md | 36 +++++++++++-- docs/usage/workflows.md | 46 ++++++++++++++++ examples/README.md | 3 ++ examples/workflows/joiner-with-onfailure.psd1 | 54 +++++++++++++++++++ 5 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 examples/workflows/joiner-with-onfailure.psd1 diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 4b48482a..1def3025 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -59,9 +59,14 @@ IdLE uses multiple configuration layers with clear boundaries: - Defines lifecycle intent - Declares steps, conditions, and parameters +- Declares optional OnFailureSteps for cleanup/rollback - Is environment-agnostic - Stored as version-controlled files (e.g. PSD1) +**OnFailureSteps** are an optional workflow section that defines cleanup or rollback steps +executed when primary steps fail. They run in best-effort mode: each OnFailure step is attempted +regardless of previous OnFailure step failures. + #### Execution request - Describes *why* a workflow is executed diff --git a/docs/usage/steps.md b/docs/usage/steps.md index e81acf24..afb90dbe 100644 --- a/docs/usage/steps.md +++ b/docs/usage/steps.md @@ -66,10 +66,40 @@ Security and portability: ## Error behavior -IdLE uses a fail-fast execution model in V1: +### Primary steps (fail-fast) -- a failing step stops plan execution -- results and events capture what happened +IdLE uses a **fail-fast execution model** for primary workflow steps: + +- A failing step stops plan execution immediately +- Subsequent primary steps are not executed +- Results and events capture what happened up to the failure + +### OnFailureSteps (best-effort) + +When primary steps fail, workflows can define **OnFailureSteps** for cleanup or rollback. + +OnFailureSteps are executed in **best-effort mode**: + +- Each OnFailure step is attempted regardless of previous OnFailure step failures +- OnFailure step failures do not stop execution of remaining OnFailure steps +- The overall execution status remains 'Failed' even if all OnFailure steps succeed + +**Execution result structure:** + +```powershell +$result.Status # 'Failed' when primary steps fail +$result.Steps # Array of primary step results (only executed steps) +$result.OnFailure.Status # 'NotRun', 'Completed', or 'PartiallyFailed' +$result.OnFailure.Steps # Array of OnFailure step results +``` + +**OnFailure status values:** + +- `NotRun`: No primary steps failed, OnFailure steps were not executed +- `Completed`: All OnFailure steps succeeded +- `PartiallyFailed`: At least one OnFailure step failed, but execution continued + +For details on declaring OnFailureSteps, see [Workflows](workflows.md). ## Built-in steps (starter pack) diff --git a/docs/usage/workflows.md b/docs/usage/workflows.md index 992a8682..df02f4a6 100644 --- a/docs/usage/workflows.md +++ b/docs/usage/workflows.md @@ -25,6 +25,52 @@ Example: } ``` +### OnFailureSteps (optional) + +Workflows can define cleanup or rollback steps that run when primary steps fail. +OnFailureSteps are executed in **best-effort mode**: + +- They run only if at least one primary step fails +- Each OnFailure step is attempted regardless of previous OnFailure step failures +- OnFailure step failures do not stop execution of remaining OnFailure steps +- The overall execution status remains 'Failed' even if all OnFailure steps succeed + +Example: + +```powershell +@{ + Name = 'Joiner - With Cleanup' + LifecycleEvent = 'Joiner' + + Steps = @( + @{ Name = 'CreateAccount'; Type = 'IdLE.Step.CreateAccount' } + @{ Name = 'AssignLicense'; Type = 'IdLE.Step.AssignLicense' } + ) + + OnFailureSteps = @( + @{ Name = 'NotifyAdmin'; Type = 'IdLE.Step.SendEmail'; With = @{ Recipient = 'admin@example.com' } } + @{ Name = 'RollbackAccount'; Type = 'IdLE.Step.DeleteAccount' } + @{ Name = 'LogFailure'; Type = 'IdLE.Step.LogToDatabase' } + ) +} +``` + +**Best practices:** + +- Use OnFailureSteps for notifications, logging, or rollback operations +- Keep OnFailure steps simple and resilient +- Avoid dependencies between OnFailure steps +- Don't assume OnFailure steps will always succeed + +**Execution result:** + +The execution result includes a separate `OnFailure` section: + +```powershell +$result.OnFailure.Status # 'NotRun', 'Completed', or 'PartiallyFailed' +$result.OnFailure.Steps # Array of OnFailure step results +``` + ## Planning and validation Workflows are validated during planning. diff --git a/examples/README.md b/examples/README.md index ff72efca..7557a0ff 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,7 +24,10 @@ Workflow samples are located in: Highlighted samples: +- `joiner-minimal.psd1` — minimal workflow with a single EmitEvent step +- `joiner-with-condition.psd1` — demonstrates conditional step execution - `joiner-ensureentitlement.psd1` — ensures a demo group assignment via the built-in EnsureEntitlement step +- `joiner-with-onfailure.psd1` — demonstrates OnFailureSteps for cleanup and notifications Workflows are **data-only** PSD1 files. A minimal workflow looks like: diff --git a/examples/workflows/joiner-with-onfailure.psd1 b/examples/workflows/joiner-with-onfailure.psd1 new file mode 100644 index 00000000..f8e0b326 --- /dev/null +++ b/examples/workflows/joiner-with-onfailure.psd1 @@ -0,0 +1,54 @@ +@{ + Name = 'Joiner - With OnFailure Cleanup' + LifecycleEvent = 'Joiner' + Description = 'Demonstrates OnFailureSteps for cleanup and notifications when primary steps fail' + + Steps = @( + @{ + Name = 'Emit start' + Type = 'IdLE.Step.EmitEvent' + With = @{ Message = 'Starting Joiner workflow with OnFailure handling' } + } + @{ + Name = 'Ensure Department' + Type = 'IdLE.Step.EnsureAttribute' + With = @{ + IdentityKey = 'user1' + Name = 'Department' + Value = 'IT' + Provider = 'Identity' + } + RequiresCapabilities = 'Identity.Attribute.Ensure' + } + @{ + Name = 'Assign demo group' + Type = 'IdLE.Step.EnsureEntitlement' + With = @{ + IdentityKey = 'user1' + Entitlement = @{ + Kind = 'Group' + Id = 'demo-group' + DisplayName = 'Demo Group' + } + State = 'Present' + Provider = 'Identity' + } + RequiresCapabilities = @('IdLE.Entitlement.List', 'IdLE.Entitlement.Grant') + } + ) + + OnFailureSteps = @( + @{ + Name = 'Log failure' + Type = 'IdLE.Step.EmitEvent' + Description = 'Emits a custom event to log the failure' + With = @{ Message = 'Workflow execution failed - cleanup initiated' } + } + @{ + Name = 'Notify administrator' + Type = 'IdLE.Step.EmitEvent' + Description = 'Simulates sending a notification to administrators' + With = @{ Message = 'ALERT: Joiner workflow failed for user1 - manual intervention required' } + } + ) +} From cbbc46c1e99815f2d686db7186b069e339efb108 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <55826276+ntt-matthias-fleschuetz@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:21:04 +0100 Subject: [PATCH 21/21] security: Reinstate ScriptBlock validation in Invoke-IdlePlanObject Addresses PR review feedback: Invoke-IdlePlanObject now validates Plan and Providers inputs against ScriptBlocks before execution to enforce the documented data-only security boundary. This prevents untrusted inputs (e.g., deserialized plans) from passing executable code into the execution context. Changes: - Add Assert-IdleNoScriptBlock checks for Plan and Providers parameters - Add regression tests for ScriptBlock rejection in both inputs Fixes security boundary violation identified in PR #57 review. --- .../Public/Invoke-IdlePlanObject.ps1 | 4 +++ tests/Invoke-IdlePlan.Tests.ps1 | 35 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 index 0c958580..5bb0feac 100644 --- a/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/Invoke-IdlePlanObject.ps1 @@ -139,6 +139,10 @@ function Invoke-IdlePlanObject { # 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. + Assert-IdleNoScriptBlock -InputObject $Plan -Path 'Plan' + Assert-IdleNoScriptBlock -InputObject $Providers -Path 'Providers' + # StepRegistry is constructed via helper to ensure built-in steps and host-provided steps can co-exist. $stepRegistry = Get-IdleStepRegistry -Providers $Providers diff --git a/tests/Invoke-IdlePlan.Tests.ps1 b/tests/Invoke-IdlePlan.Tests.ps1 index 89a8b4bc..485b6160 100644 --- a/tests/Invoke-IdlePlan.Tests.ps1 +++ b/tests/Invoke-IdlePlan.Tests.ps1 @@ -400,4 +400,37 @@ Describe 'Invoke-IdlePlan' { $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' { New-IdlePlan -WorkflowPath $wfPath -Request $req } | Should -Throw } -} + + It 'rejects ScriptBlock in Plan object' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @( + @{ + Name = 'TestStep' + Type = 'Test' + With = @{ + Payload = { Write-Host 'Should not execute' } + } + } + ) + } + + { Invoke-IdlePlan -Plan $plan } | Should -Throw '*ScriptBlocks are not allowed*' + } + + It 'rejects ScriptBlock in Providers object' { + $plan = [pscustomobject]@{ + PSTypeName = 'IdLE.Plan' + CorrelationId = 'test-corr' + Steps = @() + } + + $providers = @{ + Config = @{ + Secret = { Get-Secret } + } + } + + { Invoke-IdlePlan -Plan $plan -Providers $providers } | Should -Throw '*ScriptBlocks are not allowed*' + }} \ No newline at end of file