Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion docs/use/workflows/conditions.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
title: Conditions
sidebar_label: Conditions
Expand Down Expand Up @@ -68,7 +68,41 @@

---

## Condition DSL
## Conditions as a guard against plan-time validation
Comment thread
blindzero marked this conversation as resolved.

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.

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, `Exists` evaluates to `false`, the step is `NotApplicable`, and neither the condition path nor the `With` block causes a planning error.

```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 behavior is unchanged.

---

## Conditions DSL

Preconditions use the **same DSL** as Conditions.
This section is the authoritative DSL reference.
Expand Down
115 changes: 65 additions & 50 deletions src/IdLE.Core/Private/ConvertTo-IdleWorkflowSteps.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ 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.

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.
Expand Down Expand Up @@ -80,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) {
Expand Down Expand Up @@ -118,65 +128,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') {
Comment thread
blindzero marked this conversation as resolved.
# 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'
)
}
}
}
}
}
}
Expand Down
94 changes: 94 additions & 0 deletions tests/Core/New-IdlePlan.Tests.ps1
Comment thread
blindzero marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,100 @@ 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'

$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 = Join-Path $script:FixturesPath 'condition-skip-schema.psd1'

$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 = Join-Path $script:FixturesPath 'condition-applicable-schema.psd1'

$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 = Join-Path $script:FixturesPath 'condition-skip-onfailure.psd1'

$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 @'
Expand Down
11 changes: 11 additions & 0 deletions tests/fixtures/workflows/condition-applicable-schema.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@{
Name = 'Condition Applicable Schema'
LifecycleEvent = 'Joiner'
Steps = @(
@{
Name = 'StrictStep'
Type = 'IdLE.Step.StrictApplicableTest'
Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Joiner' } }
}
)
}
14 changes: 14 additions & 0 deletions tests/fixtures/workflows/condition-exists-absent.psd1
Original file line number Diff line number Diff line change
@@ -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}}'
}
}
)
}
17 changes: 17 additions & 0 deletions tests/fixtures/workflows/condition-skip-onfailure.psd1
Original file line number Diff line number Diff line change
@@ -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}}'
}
}
)
}
11 changes: 11 additions & 0 deletions tests/fixtures/workflows/condition-skip-schema.psd1
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@{
Name = 'Condition Skip Schema'
LifecycleEvent = 'Joiner'
Steps = @(
@{
Name = 'StrictStep'
Type = 'IdLE.Step.StrictSchemaTest'
Condition = @{ Equals = @{ Path = 'Plan.LifecycleEvent'; Value = 'Leaver' } }
}
)
}
14 changes: 14 additions & 0 deletions tests/fixtures/workflows/condition-skip-template.psd1
Original file line number Diff line number Diff line change
@@ -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}}'
}
}
)
}
Loading