From d2185803501a4c847814dd3bde74d1581bd7119e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:47:31 +0000 Subject: [PATCH 1/5] Initial plan From 0564b26456c30e34c6aa4d533043bf39a145e60d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:52:33 +0000 Subject: [PATCH 2/5] fix: skip With template resolution and WithSchema validation for condition-skipped steps Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/use/workflows/conditions.md | 34 ++++- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 109 +++++++------- tests/Core/New-IdlePlan.Tests.ps1 | 134 ++++++++++++++++++ 3 files changed, 227 insertions(+), 50 deletions(-) diff --git a/docs/use/workflows/conditions.md b/docs/use/workflows/conditions.md index bc21e7c4..87a27920 100644 --- a/docs/use/workflows/conditions.md +++ b/docs/use/workflows/conditions.md @@ -68,7 +68,39 @@ If any condition evaluates to false, the step is marked as `NotApplicable` durin --- -## Condition DSL +## Conditions as a guard against plan-time validation + +When a step's `Condition` evaluates to `false`, IdLE marks it `NotApplicable` and **skips all remaining plan-time processing** for that step, including: + +- `With` template resolution +- `WithSchema` validation + +This means a condition-guarded step will **not** cause a planning failure even if its `With` block references data that is absent or if required schema keys are missing. The step is simply excluded from the executable plan. + +:::info Example: Guard a step with an existence check +A step that provisions an EU-region user can be safely guarded by a condition that checks for the `Region` attribute. +If the attribute is absent, the step is `NotApplicable` and its `With` block — which may reference `{{Request.Context.Views.Identity.Profile.Attributes.Region}}` — is never processed. + +```powershell +@{ + Name = 'Provision EU User' + Type = 'IdLE.Step.EnsureAttributes' + Condition = @{ Exists = 'Request.Context.Views.Identity.Profile.Attributes.Region' } + With = @{ + IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' + Attributes = @{ Region = '{{Request.Context.Views.Identity.Profile.Attributes.Region}}' } + } +} +``` + +If `Region` is absent, the condition evaluates to `false`, the step is `NotApplicable`, and no template errors are raised. +::: + +**Applicable steps** still undergo full template resolution and schema validation — this behaviour is unchanged. + +--- + + Preconditions use the **same DSL** as Conditions. This section is the authoritative DSL reference. diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index fd130637..4535ae4a 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -8,6 +8,12 @@ function ConvertTo-IdleWorkflowSteps { .DESCRIPTION Evaluates Condition during planning and sets Status = Planned / NotApplicable. + When a step's Condition evaluates to false the step is marked NotApplicable and all + subsequent plan-time processing that only makes sense for executable steps (With template + resolution and WithSchema validation) is skipped for that step. This prevents false-positive + planning failures caused by missing data referenced in With blocks that are intentionally + guarded by a Condition. + 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. @@ -118,65 +124,70 @@ function ConvertTo-IdleWorkflowSteps { @{} } - # Resolve template placeholders in With (planning-time resolution) - $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName + # Skip With template resolution and WithSchema validation for NotApplicable steps. + # A step whose Condition evaluated to false will never be executed, so further plan-time + # validation that assumes the step is eligible would produce false-positive failures. + if ($status -ne 'NotApplicable') { + # Resolve template placeholders in With (planning-time resolution) + $with = Resolve-IdleWorkflowTemplates -Value $with -Request $PlanningContext.Request -StepName $stepName - # Validate WithSchema declared by step metadata (fail-fast plan-time schema check). - # Every step type must declare WithSchema. Required keys must be present; unknown keys are rejected. - # If OptionalKeys contains '*', any additional key is accepted (permissive schema for test/internal use). - if ($StepMetadataCatalog.ContainsKey($stepType)) { - $md = $StepMetadataCatalog[$stepType] - if ($null -ne $md -and $md -is [hashtable] -and $md.ContainsKey('WithSchema')) { - $schema = $md['WithSchema'] - if ($null -ne $schema -and $schema -is [hashtable]) { - $requiredKeys = @() - if ($schema.ContainsKey('RequiredKeys') -and $null -ne $schema['RequiredKeys']) { - $requiredKeys = @($schema['RequiredKeys']) - } - $optionalKeys = @() - if ($schema.ContainsKey('OptionalKeys') -and $null -ne $schema['OptionalKeys']) { - $optionalKeys = @($schema['OptionalKeys']) - } + # Validate WithSchema declared by step metadata (fail-fast plan-time schema check). + # Every step type must declare WithSchema. Required keys must be present; unknown keys are rejected. + # If OptionalKeys contains '*', any additional key is accepted (permissive schema for test/internal use). + if ($StepMetadataCatalog.ContainsKey($stepType)) { + $md = $StepMetadataCatalog[$stepType] + if ($null -ne $md -and $md -is [hashtable] -and $md.ContainsKey('WithSchema')) { + $schema = $md['WithSchema'] + if ($null -ne $schema -and $schema -is [hashtable]) { + $requiredKeys = @() + if ($schema.ContainsKey('RequiredKeys') -and $null -ne $schema['RequiredKeys']) { + $requiredKeys = @($schema['RequiredKeys']) + } + $optionalKeys = @() + if ($schema.ContainsKey('OptionalKeys') -and $null -ne $schema['OptionalKeys']) { + $optionalKeys = @($schema['OptionalKeys']) + } - # Build allowed set from all keys (required and optional combined) - $allAllowedKeysList = [System.Collections.Generic.List[string]]::new() - foreach ($keyList in @($requiredKeys, $optionalKeys)) { - foreach ($k in $keyList) { - if ($null -ne $k -and -not [string]::IsNullOrWhiteSpace([string]$k)) { - $null = $allAllowedKeysList.Add([string]$k) + # Build allowed set from all keys (required and optional combined) + $allAllowedKeysList = [System.Collections.Generic.List[string]]::new() + foreach ($keyList in @($requiredKeys, $optionalKeys)) { + foreach ($k in $keyList) { + if ($null -ne $k -and -not [string]::IsNullOrWhiteSpace([string]$k)) { + $null = $allAllowedKeysList.Add([string]$k) + } } } - } - $allowedSet = [System.Collections.Generic.HashSet[string]]::new( - $allAllowedKeysList, - [System.StringComparer]::OrdinalIgnoreCase - ) - $permissive = $allowedSet.Contains('*') - - # Validate required keys are present - foreach ($rk in $requiredKeys) { - if ([string]::IsNullOrWhiteSpace([string]$rk) -or [string]$rk -eq '*') { continue } - - if (-not $with.ContainsKey($rk)) { - $requiredList = [string]::Join(', ', ($requiredKeys | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace([string]$_) } | Sort-Object)) - throw [System.ArgumentException]::new( - ("Step '{0}' (type '{1}') is missing required With.{2}. Required With keys: {3}." -f $stepName, $stepType, $rk, $requiredList), - 'Workflow' - ) - } - } + $allowedSet = [System.Collections.Generic.HashSet[string]]::new( + $allAllowedKeysList, + [System.StringComparer]::OrdinalIgnoreCase + ) + $permissive = $allowedSet.Contains('*') + + # Validate required keys are present + foreach ($rk in $requiredKeys) { + if ([string]::IsNullOrWhiteSpace([string]$rk) -or [string]$rk -eq '*') { continue } - # Validate no unknown keys (skip if permissive wildcard) - if (-not $permissive) { - foreach ($wk in @($with.Keys)) { - if (-not $allowedSet.Contains([string]$wk)) { - $supportedList = [string]::Join(', ', ($allAllowedKeysList | Sort-Object)) + if (-not $with.ContainsKey($rk)) { + $requiredList = [string]::Join(', ', ($requiredKeys | Where-Object { $_ -ne '*' -and -not [string]::IsNullOrWhiteSpace([string]$_) } | Sort-Object)) throw [System.ArgumentException]::new( - ("Step '{0}' (type '{1}') does not support With.{2}. Supported With keys: {3}." -f $stepName, $stepType, [string]$wk, $supportedList), + ("Step '{0}' (type '{1}') is missing required With.{2}. Required With keys: {3}." -f $stepName, $stepType, $rk, $requiredList), 'Workflow' ) } } + + # Validate no unknown keys (skip if permissive wildcard) + if (-not $permissive) { + foreach ($wk in @($with.Keys)) { + if (-not $allowedSet.Contains([string]$wk)) { + $supportedList = [string]::Join(', ', ($allAllowedKeysList | Sort-Object)) + throw [System.ArgumentException]::new( + ("Step '{0}' (type '{1}') does not support With.{2}. Supported With keys: {3}." -f $stepName, $stepType, [string]$wk, $supportedList), + 'Workflow' + ) + } + } + } } } } diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index ffe39f77..c17d1671 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -178,6 +178,140 @@ Describe 'New-IdlePlan' { } } + Context 'Condition skips With processing' { + It 'does not fail planning when condition is false and With references missing data (template resolution skipped)' { + $wfPath = New-IdleTestWorkflowFile -FileName 'condition-skip-template.psd1' -Content @' +@{ + Name = 'Condition Skip Template' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'ConditionalStep' + Type = 'IdLE.Step.ConditionalSkipTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + With = @{ + Value = '{{Request.Intent.MissingKey}}' + } + } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.ConditionalSkipTest' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ConditionalSkipTest') + } + + # Must not throw despite template referencing absent Request.Intent.MissingKey + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + @($plan.Steps).Count | Should -Be 1 + $plan.Steps[0].Status | Should -Be 'NotApplicable' + } + + It 'does not fail planning when condition is false and With is missing a required schema key' { + $wfPath = New-IdleTestWorkflowFile -FileName 'condition-skip-schema.psd1' -Content @' +@{ + Name = 'Condition Skip Schema' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'StrictStep' + Type = 'IdLE.Step.StrictSchemaTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.StrictSchemaTest' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.StrictSchemaTest') -WithSchemas @{ + 'IdLE.Step.StrictSchemaTest' = @{ RequiredKeys = @('IdentityKey'); OptionalKeys = @() } + } + } + + # Must not throw despite the required With.IdentityKey being absent + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + @($plan.Steps).Count | Should -Be 1 + $plan.Steps[0].Status | Should -Be 'NotApplicable' + } + + It 'still enforces With template resolution and WithSchema validation when condition is true' { + $wfPath = New-IdleTestWorkflowFile -FileName 'condition-applicable-schema.psd1' -Content @' +@{ + Name = 'Condition Applicable Schema' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'StrictStep' + Type = 'IdLE.Step.StrictApplicableTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.StrictApplicableTest' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.StrictApplicableTest') -WithSchemas @{ + 'IdLE.Step.StrictApplicableTest' = @{ RequiredKeys = @('IdentityKey'); OptionalKeys = @() } + } + } + + # Must still throw because condition is true and required With.IdentityKey is missing + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*IdentityKey*' + } + + It 'does not fail planning when condition is false for an OnFailureStep referencing missing template data' { + $wfPath = New-IdleTestWorkflowFile -FileName 'condition-skip-onfailure.psd1' -Content @' +@{ + Name = 'Condition Skip OnFailureStep' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Primary'; Type = 'IdLE.Step.PrimarySkipTest' } + ) + OnFailureSteps = @( + @{ + Name = 'SkippedOnFailure' + Type = 'IdLE.Step.OnFailureSkipTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + With = @{ + Value = '{{Request.Intent.MissingKey}}' + } + } + ) +} +'@ + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.PrimarySkipTest' = 'Invoke-IdleTestNoopStep' + 'IdLE.Step.OnFailureSkipTest' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @( + 'IdLE.Step.PrimarySkipTest', + 'IdLE.Step.OnFailureSkipTest' + ) + } + + # Must not throw despite template referencing absent data + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + @($plan.OnFailureSteps).Count | Should -Be 1 + $plan.OnFailureSteps[0].Status | Should -Be 'NotApplicable' + } + } + Context 'Validation' { It 'throws when request LifecycleEvent does not match workflow LifecycleEvent' { $wfPath = New-IdleTestWorkflowFile -FileName 'joiner.psd1' -Content @' From 96ebc22fe8665c65fad865b2a43a6c8d3bfab244 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:37:20 +0000 Subject: [PATCH 3/5] refactor: move condition-skip test workflows to fixture files, fix Conditions DSL heading Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/use/workflows/conditions.md | 2 +- tests/Core/New-IdlePlan.Tests.ps1 | 65 ++----------------- .../condition-applicable-schema.psd1 | 11 ++++ .../workflows/condition-skip-onfailure.psd1 | 17 +++++ .../workflows/condition-skip-schema.psd1 | 11 ++++ .../workflows/condition-skip-template.psd1 | 14 ++++ 6 files changed, 58 insertions(+), 62 deletions(-) create mode 100644 tests/fixtures/workflows/condition-applicable-schema.psd1 create mode 100644 tests/fixtures/workflows/condition-skip-onfailure.psd1 create mode 100644 tests/fixtures/workflows/condition-skip-schema.psd1 create mode 100644 tests/fixtures/workflows/condition-skip-template.psd1 diff --git a/docs/use/workflows/conditions.md b/docs/use/workflows/conditions.md index 87a27920..a829d226 100644 --- a/docs/use/workflows/conditions.md +++ b/docs/use/workflows/conditions.md @@ -100,7 +100,7 @@ If `Region` is absent, the condition evaluates to `false`, the step is `NotAppli --- - +## Conditions DSL Preconditions use the **same DSL** as Conditions. This section is the authoritative DSL reference. diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index c17d1671..8b2a32cc 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -180,22 +180,7 @@ Describe 'New-IdlePlan' { Context 'Condition skips With processing' { It 'does not fail planning when condition is false and With references missing data (template resolution skipped)' { - $wfPath = New-IdleTestWorkflowFile -FileName 'condition-skip-template.psd1' -Content @' -@{ - Name = 'Condition Skip Template' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'ConditionalStep' - Type = 'IdLE.Step.ConditionalSkipTest' - Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } - With = @{ - Value = '{{Request.Intent.MissingKey}}' - } - } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'condition-skip-template.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' $providers = @{ @@ -212,19 +197,7 @@ Describe 'New-IdlePlan' { } It 'does not fail planning when condition is false and With is missing a required schema key' { - $wfPath = New-IdleTestWorkflowFile -FileName 'condition-skip-schema.psd1' -Content @' -@{ - Name = 'Condition Skip Schema' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'StrictStep' - Type = 'IdLE.Step.StrictSchemaTest' - Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } - } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'condition-skip-schema.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' $providers = @{ @@ -243,19 +216,7 @@ Describe 'New-IdlePlan' { } It 'still enforces With template resolution and WithSchema validation when condition is true' { - $wfPath = New-IdleTestWorkflowFile -FileName 'condition-applicable-schema.psd1' -Content @' -@{ - Name = 'Condition Applicable Schema' - LifecycleEvent = 'Joiner' - Steps = @( - @{ - Name = 'StrictStep' - Type = 'IdLE.Step.StrictApplicableTest' - Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } - } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'condition-applicable-schema.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' $providers = @{ @@ -271,25 +232,7 @@ Describe 'New-IdlePlan' { } It 'does not fail planning when condition is false for an OnFailureStep referencing missing template data' { - $wfPath = New-IdleTestWorkflowFile -FileName 'condition-skip-onfailure.psd1' -Content @' -@{ - Name = 'Condition Skip OnFailureStep' - LifecycleEvent = 'Joiner' - Steps = @( - @{ Name = 'Primary'; Type = 'IdLE.Step.PrimarySkipTest' } - ) - OnFailureSteps = @( - @{ - Name = 'SkippedOnFailure' - Type = 'IdLE.Step.OnFailureSkipTest' - Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } - With = @{ - Value = '{{Request.Intent.MissingKey}}' - } - } - ) -} -'@ + $wfPath = Join-Path $script:FixturesPath 'condition-skip-onfailure.psd1' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' $providers = @{ diff --git a/tests/fixtures/workflows/condition-applicable-schema.psd1 b/tests/fixtures/workflows/condition-applicable-schema.psd1 new file mode 100644 index 00000000..746eb9d5 --- /dev/null +++ b/tests/fixtures/workflows/condition-applicable-schema.psd1 @@ -0,0 +1,11 @@ +@{ + Name = 'Condition Applicable Schema' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'StrictStep' + Type = 'IdLE.Step.StrictApplicableTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } } + } + ) +} diff --git a/tests/fixtures/workflows/condition-skip-onfailure.psd1 b/tests/fixtures/workflows/condition-skip-onfailure.psd1 new file mode 100644 index 00000000..d7013930 --- /dev/null +++ b/tests/fixtures/workflows/condition-skip-onfailure.psd1 @@ -0,0 +1,17 @@ +@{ + Name = 'Condition Skip OnFailureStep' + LifecycleEvent = 'Joiner' + Steps = @( + @{ Name = 'Primary'; Type = 'IdLE.Step.PrimarySkipTest' } + ) + OnFailureSteps = @( + @{ + Name = 'SkippedOnFailure' + Type = 'IdLE.Step.OnFailureSkipTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + With = @{ + Value = '{{Request.Intent.MissingKey}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/condition-skip-schema.psd1 b/tests/fixtures/workflows/condition-skip-schema.psd1 new file mode 100644 index 00000000..a62f5a2e --- /dev/null +++ b/tests/fixtures/workflows/condition-skip-schema.psd1 @@ -0,0 +1,11 @@ +@{ + Name = 'Condition Skip Schema' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'StrictStep' + Type = 'IdLE.Step.StrictSchemaTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + } + ) +} diff --git a/tests/fixtures/workflows/condition-skip-template.psd1 b/tests/fixtures/workflows/condition-skip-template.psd1 new file mode 100644 index 00000000..eec3b3d7 --- /dev/null +++ b/tests/fixtures/workflows/condition-skip-template.psd1 @@ -0,0 +1,14 @@ +@{ + Name = 'Condition Skip Template' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'ConditionalStep' + Type = 'IdLE.Step.ConditionalSkipTest' + Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } } + With = @{ + Value = '{{Request.Intent.MissingKey}}' + } + } + ) +} From c515babb81755d2d0021cde4dd761a46442e036d Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:06:31 +0100 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/use/workflows/conditions.md | 2 +- src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/use/workflows/conditions.md b/docs/use/workflows/conditions.md index a829d226..4229145a 100644 --- a/docs/use/workflows/conditions.md +++ b/docs/use/workflows/conditions.md @@ -96,7 +96,7 @@ If the attribute is absent, the step is `NotApplicable` and its `With` block — If `Region` is absent, the condition evaluates to `false`, the step is `NotApplicable`, and no template errors are raised. ::: -**Applicable steps** still undergo full template resolution and schema validation — this behaviour is unchanged. +**Applicable steps** still undergo full template resolution and schema validation — this behavior is unchanged. --- diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index 4535ae4a..e9d064da 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -8,9 +8,9 @@ function ConvertTo-IdleWorkflowSteps { .DESCRIPTION Evaluates Condition during planning and sets Status = Planned / NotApplicable. - When a step's Condition evaluates to false the step is marked NotApplicable and all + When a step's Condition evaluates to false, the step is marked NotApplicable and all subsequent plan-time processing that only makes sense for executable steps (With template - resolution and WithSchema validation) is skipped for that step. This prevents false-positive + resolution and WithSchema validation) is skipped for that step. This prevents false-positive planning failures caused by missing data referenced in With blocks that are intentionally guarded by a Condition. From 45de1a999678c0122d2930ef0ddd6e6defc80c7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 08:12:15 +0000 Subject: [PATCH 5/5] fix: exclude Exists operator paths from Condition path validation during plan building Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/use/workflows/conditions.md | 4 +++- .../Private/ConvertTo-IdleWorkflowSteps.ps1 | 6 +++++- tests/Core/New-IdlePlan.Tests.ps1 | 17 +++++++++++++++++ .../workflows/condition-exists-absent.psd1 | 14 ++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/workflows/condition-exists-absent.psd1 diff --git a/docs/use/workflows/conditions.md b/docs/use/workflows/conditions.md index 4229145a..63de2978 100644 --- a/docs/use/workflows/conditions.md +++ b/docs/use/workflows/conditions.md @@ -77,9 +77,11 @@ When a step's `Condition` evaluates to `false`, IdLE marks it `NotApplicable` an This means a condition-guarded step will **not** cause a planning failure even if its `With` block references data that is absent or if required schema keys are missing. The step is simply excluded from the executable plan. +The `Exists` operator is specifically designed for this pattern: using `Exists` in a `Condition` does **not** require the referenced path to exist at plan time. If the path is absent, `Exists` evaluates to `false` and the step becomes `NotApplicable`. + :::info Example: Guard a step with an existence check A step that provisions an EU-region user can be safely guarded by a condition that checks for the `Region` attribute. -If the attribute is absent, the step is `NotApplicable` and its `With` block — which may reference `{{Request.Context.Views.Identity.Profile.Attributes.Region}}` — is never processed. +If the attribute is absent, `Exists` evaluates to `false`, the step is `NotApplicable`, and neither the condition path nor the `With` block causes a planning error. ```powershell @{ diff --git a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 index e9d064da..a9d2b3cc 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1 @@ -14,6 +14,10 @@ function ConvertTo-IdleWorkflowSteps { planning failures caused by missing data referenced in With blocks that are intentionally guarded by a Condition. + Condition path validation uses -ExcludeExistsOperatorPaths so that steps using the Exists + operator to guard optional context attributes are not rejected at plan time merely because + the attribute is absent — that is the intended use of the Exists operator. + 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. @@ -86,7 +90,7 @@ function ConvertTo-IdleWorkflowSteps { ) } - Assert-IdleConditionPathsResolvable -Condition $condition -Context $PlanningContext -StepName $stepName -Source 'Condition' + Assert-IdleConditionPathsResolvable -Condition $condition -Context $PlanningContext -StepName $stepName -Source 'Condition' -ExcludeExistsOperatorPaths $isApplicable = Test-IdleCondition -Condition $condition -Context $PlanningContext if (-not $isApplicable) { diff --git a/tests/Core/New-IdlePlan.Tests.ps1 b/tests/Core/New-IdlePlan.Tests.ps1 index 8b2a32cc..60391abb 100644 --- a/tests/Core/New-IdlePlan.Tests.ps1 +++ b/tests/Core/New-IdlePlan.Tests.ps1 @@ -179,6 +179,23 @@ Describe 'New-IdlePlan' { } Context 'Condition skips With processing' { + It 'does not fail planning when condition uses Exists operator on an absent context path' { + $wfPath = Join-Path $script:FixturesPath 'condition-exists-absent.psd1' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.ExistsConditionTest' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.ExistsConditionTest') + } + + # Must not throw: Exists on absent path evaluates to false without a path-resolvability error + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan | Should -Not -BeNullOrEmpty + @($plan.Steps).Count | Should -Be 1 + $plan.Steps[0].Status | Should -Be 'NotApplicable' + } + It 'does not fail planning when condition is false and With references missing data (template resolution skipped)' { $wfPath = Join-Path $script:FixturesPath 'condition-skip-template.psd1' diff --git a/tests/fixtures/workflows/condition-exists-absent.psd1 b/tests/fixtures/workflows/condition-exists-absent.psd1 new file mode 100644 index 00000000..43ebdcc7 --- /dev/null +++ b/tests/fixtures/workflows/condition-exists-absent.psd1 @@ -0,0 +1,14 @@ +@{ + Name = 'Condition Exists Absent Path' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'ConditionalExistsStep' + Type = 'IdLE.Step.ExistsConditionTest' + Condition = @{ Exists = 'Request.Context.Views.Identity.Profile.Attributes.Region' } + With = @{ + Value = '{{Request.Context.Views.Identity.Profile.Attributes.Region}}' + } + } + ) +}