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
2 changes: 1 addition & 1 deletion docs/use/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Each step supports several optional execution control properties:
| Property | Evaluated at | Purpose |
|---|---|---|
| `Condition` | Plan time | Include or skip the step based on request/intent data. |
| `Preconditions` | Execution time (runtime) | Guard the step against stale or unsafe state immediately before it runs. See [Runtime Preconditions](workflows/preconditions.md). |
| `Precondition` | Execution time (runtime) | Guard the step against stale or unsafe state immediately before it runs. See [Runtime Preconditions](workflows/preconditions.md). |
| `OnFailureSteps` | After failure (workflow-level) | Cleanup/rollback steps run after a primary step fails. |

---
Expand Down
16 changes: 11 additions & 5 deletions docs/use/workflows/preconditions.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Add these optional properties to a workflow step definition:

| Property | Type | Required | Description |
|---|---|---|---|
| `Preconditions` | `Array[Condition]` | No | One or more condition nodes (same DSL as `Condition`). All must pass for the step to execute. |
| `Precondition` | `Condition` | No | One condition node (same DSL as `Condition`). It must evaluate to true for the step to execute. |
| `OnPreconditionFalse` | `String` | No | Behavior when a precondition fails. `Blocked` (default), `Fail`, or `Continue`. |
| `PreconditionEvent` | `Hashtable` | No | Structured event emitted when a precondition fails. |

Expand Down Expand Up @@ -72,14 +72,16 @@ Add these optional properties to a workflow step definition:
# Runtime guard: only execute if BYOD wipe is confirmed.
# Note: the condition DSL compares values as strings.
# Request.Context.Byod.WipeConfirmed must be the string 'true' (e.g. set by a ContextResolver).
Preconditions = @(
Precondition = @{
All = @(
@{
Equals = @{
Path = 'Request.Context.Byod.WipeConfirmed'
Value = 'true'
}
}
)
)
}
OnPreconditionFalse = 'Blocked'
PreconditionEvent = @{
Type = 'ManualActionRequired'
Expand All @@ -97,7 +99,7 @@ Add these optional properties to a workflow step definition:

## Condition DSL

Each entry in `Preconditions` uses the same **declarative condition DSL** as the `Condition`
`Precondition` uses the same **declarative condition DSL** as the `Condition`
property. Supported operators:

| Operator | Shape | Description |
Expand All @@ -122,6 +124,10 @@ Paths are resolved against the **execution-time context**, which includes:
A leading `context.` prefix is ignored for readability (e.g. `context.Request.Intent.Department`
resolves identically to `Request.Intent.Department`).

At planning time, IdLE validates `Path` references to fail fast on typos and wrong roots. For `Precondition`, unresolved paths under `Request.Context.*` are treated as soft (non-fatal) to support context enrichment that may arrive later at runtime (for example via host/runtime context resolver behavior). Other unresolved roots still fail fast.
When this soft-check path is used, IdLE records a planning warning (`PreconditionContextPathUnresolvedAtPlan`) in `Plan.Warnings`, and the warning is included in `Export-IdlePlan` output for CI policy checks.


---

## Blocked vs. Failed vs. Continue outcomes
Expand Down Expand Up @@ -214,7 +220,7 @@ Ensure context values are stored as strings when using `Equals` or `In` operator

## Backward compatibility

Steps without `Preconditions` behave exactly as before. Adding preconditions to a step does not
Steps without `Precondition` behave exactly as before. Adding a precondition to a step does not
affect any other steps.

---
Expand Down
167 changes: 167 additions & 0 deletions src/IdLE.Core/Private/Assert-IdleConditionPathsResolvable.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
Set-StrictMode -Version Latest

function Assert-IdleConditionPathsResolvable {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNull()]
[hashtable] $Condition,

[Parameter(Mandatory)]
[ValidateNotNull()]
[object] $Context,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $StepName,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $Source,

[Parameter()]
[switch] $AllowMissingRequestContextPaths,

[Parameter()]
[AllowNull()]
[object] $WarningSink
)

function Add-IdlePathIfPresent {
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[System.Collections.Generic.List[string]] $PathList,

[Parameter(Mandatory)]
[AllowNull()]
[object] $PathCandidate
)

if ($null -eq $PathCandidate) {
return
}

$pathText = [string]$PathCandidate
if ([string]::IsNullOrWhiteSpace($pathText)) {
return
}

if ($pathText.StartsWith('context.', [System.StringComparison]::OrdinalIgnoreCase)) {
$pathText = $pathText.Substring(8)
}

$null = $PathList.Add($pathText)
}

function Get-IdleConditionPaths {
param(
[Parameter(Mandatory)]
[System.Collections.IDictionary] $Node,

[Parameter(Mandatory)]
[AllowEmptyCollection()]
[System.Collections.Generic.List[string]] $PathList
)

if ($Node.Contains('All')) {
foreach ($child in @($Node.All)) {
if ($child -is [System.Collections.IDictionary]) {
Get-IdleConditionPaths -Node $child -PathList $PathList
}
}
return
}

if ($Node.Contains('Any')) {
foreach ($child in @($Node.Any)) {
if ($child -is [System.Collections.IDictionary]) {
Get-IdleConditionPaths -Node $child -PathList $PathList
}
}
return
}

if ($Node.Contains('None')) {
foreach ($child in @($Node.None)) {
if ($child -is [System.Collections.IDictionary]) {
Get-IdleConditionPaths -Node $child -PathList $PathList
}
}
return
}

if ($Node.Contains('Equals')) {
Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.Equals.Path
return
}

if ($Node.Contains('NotEquals')) {
Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.NotEquals.Path
return
}

if ($Node.Contains('Exists')) {
$existsVal = $Node.Exists
if ($existsVal -is [string]) {
Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal
}
elseif ($existsVal -is [System.Collections.IDictionary]) {
Add-IdlePathIfPresent -PathList $PathList -PathCandidate $existsVal.Path
}
return
}

if ($Node.Contains('In')) {
Add-IdlePathIfPresent -PathList $PathList -PathCandidate $Node.In.Path
return
}
}

$paths = [System.Collections.Generic.List[string]]::new()
Get-IdleConditionPaths -Node $Condition -PathList $paths

$uniquePaths = @($paths | Select-Object -Unique)
if ($uniquePaths.Count -eq 0) {
return
}

$missingPaths = @()
$softMissingContextPaths = @()
foreach ($path in $uniquePaths) {
if (-not (Test-IdlePathExists -Object $Context -Path $path)) {
if ($AllowMissingRequestContextPaths -and $path.StartsWith('Request.Context.')) {
$softMissingContextPaths += $path
continue
}
$missingPaths += $path
}
}

if ($softMissingContextPaths.Count -gt 0 -and $null -ne $WarningSink) {
$warningItem = [ordered]@{
Code = 'PreconditionContextPathUnresolvedAtPlan'
Type = 'Warning'
Step = $StepName
Source = $Source
Paths = @($softMissingContextPaths | Select-Object -Unique)
Message = ("Workflow step '{0}' references Request.Context path(s) in {1} that are not yet available at planning time: [{2}]. Evaluation will continue and paths may be resolved at runtime." -f $StepName, $Source, ([string]::Join(', ', @($softMissingContextPaths | Select-Object -Unique))))
}

if ($WarningSink -is [System.Collections.IList]) {
$null = $WarningSink.Add($warningItem)
}
elseif ($WarningSink -is [object[]]) {
# Fallback for fixed arrays: cannot mutate by reference safely.
# Caller should pass an IList (plan.Warnings is an ArrayList) for collection.
}
}

if ($missingPaths.Count -gt 0) {
$missingPathList = [string]::Join(', ', $missingPaths)
throw [System.ArgumentException]::new(
("Workflow step '{0}' has unresolved condition path(s) in {1}: [{2}]. Check Request/Plan structure or ContextResolvers outputs." -f $StepName, $Source, $missingPathList),
'Workflow'
)
}
}
25 changes: 25 additions & 0 deletions src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ function ConvertTo-IdlePlanExportObject {
)

foreach ($name in $Names) {
if ($Object -is [System.Collections.IDictionary]) {
if ($Object.Contains($name)) {
return $Object[$name]
}
continue
}
$prop = $Object.PSObject.Properties[$name]
if ($null -ne $prop) {
return $prop.Value
Expand Down Expand Up @@ -273,10 +279,29 @@ function ConvertTo-IdlePlanExportObject {
$stepList += $stepMap
}


# ---- Plan warnings ------------------------------------------------------
$rawWarnings = Get-FirstPropertyValue -Object $Plan -Names @('Warnings', 'PlanningWarnings')
$warningList = @()
foreach ($w in @($rawWarnings)) {
if ($null -eq $w) { continue }

$warningMap = New-OrderedMap
$warningMap.code = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Code', 'code'))
$warningMap.type = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Type', 'type'))
$warningMap.step = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Step', 'step', 'StepName'))
$warningMap.source = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Source', 'source'))
$warningMap.paths = Get-FirstPropertyValue -Object $w -Names @('Paths', 'paths')
$warningMap.message = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Message', 'message'))

$warningList += $warningMap
}

$planMap = New-OrderedMap
$planMap.id = $planId
$planMap.mode = $mode
$planMap.steps = $stepList
$planMap.warnings = $warningList

# ---- Metadata block ------------------------------------------------------
$metadataMap = New-OrderedMap
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
Set-StrictMode -Version Latest

function ConvertTo-IdleWorkflowStepPreconditionSettings {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNull()]
[object] $Step,

[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $StepName,

[Parameter(Mandatory)]
[ValidateNotNull()]
[object] $PlanningContext
)

$normalized = @{
Precondition = $null
OnPreconditionFalse = $null
PreconditionEvent = $null
}

# Runtime Precondition: evaluated at execution time (not planning time).
# Uses the same declarative condition DSL as Condition.
if (Test-IdleWorkflowStepKey -Step $Step -Key 'Precondition') {
$rawPrecondition = Get-IdlePropertyValue -Object $Step -Name 'Precondition'
if ($null -ne $rawPrecondition) {
if ($rawPrecondition -isnot [System.Collections.IDictionary]) {
throw [System.ArgumentException]::new(
("Workflow step '{0}': Precondition must be a hashtable (condition node)." -f $StepName),
'Workflow'
)
}

$pcErrors = Test-IdleConditionSchema -Condition ([hashtable]$rawPrecondition) -StepName $StepName
if (@($pcErrors).Count -gt 0) {
throw [System.ArgumentException]::new(
("Invalid Precondition on step '{0}': {1}" -f $StepName, ([string]::Join(' ', @($pcErrors)))),
'Workflow'
)
}

$warningSink = $null
$planObj = $PlanningContext.Plan
if ($null -ne $planObj) {
if ($planObj -is [System.Collections.IDictionary]) {
if ($planObj.Contains('Warnings')) { $warningSink = $planObj['Warnings'] }
} else {
$wProp = $planObj.PSObject.Properties['Warnings']
if ($null -ne $wProp) { $warningSink = $wProp.Value }
}
}
Assert-IdleConditionPathsResolvable -Condition ([hashtable]$rawPrecondition) -Context $PlanningContext -StepName $StepName -Source 'Precondition' -AllowMissingRequestContextPaths -WarningSink $warningSink
$normalized.Precondition = Copy-IdleDataObject -Value $rawPrecondition
}
}

if (Test-IdleWorkflowStepKey -Step $Step -Key 'OnPreconditionFalse') {
$rawOnPreconditionFalseValue = Get-IdlePropertyValue -Object $Step -Name 'OnPreconditionFalse'
if ($null -ne $rawOnPreconditionFalseValue) {
$rawOnPreconditionFalse = [string]$rawOnPreconditionFalseValue
if (-not [string]::IsNullOrWhiteSpace($rawOnPreconditionFalse)) {
if ($rawOnPreconditionFalse -notin @('Blocked', 'Fail', 'Continue')) {
throw [System.ArgumentException]::new(
("Workflow step '{0}': OnPreconditionFalse must be 'Blocked', 'Fail', or 'Continue'. Got: '{1}'." -f $StepName, $rawOnPreconditionFalse),
'Workflow'
)
}

$normalized.OnPreconditionFalse = $rawOnPreconditionFalse
}
}
}

if (Test-IdleWorkflowStepKey -Step $Step -Key 'PreconditionEvent') {
$rawPreconditionEvent = Get-IdlePropertyValue -Object $Step -Name 'PreconditionEvent'
if ($null -ne $rawPreconditionEvent) {
if ($rawPreconditionEvent -isnot [System.Collections.IDictionary]) {
throw [System.ArgumentException]::new(
("Workflow step '{0}': PreconditionEvent must be a hashtable." -f $StepName),
'Workflow'
)
}

$pcEvtType = if ($rawPreconditionEvent.Contains('Type')) { [string]$rawPreconditionEvent['Type'] } else { $null }
if ([string]::IsNullOrWhiteSpace($pcEvtType)) {
throw [System.ArgumentException]::new(
("Workflow step '{0}': PreconditionEvent.Type is required and must be a non-empty string." -f $StepName),
'Workflow'
)
}

$pcEvtMsg = if ($rawPreconditionEvent.Contains('Message')) { [string]$rawPreconditionEvent['Message'] } else { $null }
if ([string]::IsNullOrWhiteSpace($pcEvtMsg)) {
throw [System.ArgumentException]::new(
("Workflow step '{0}': PreconditionEvent.Message is required and must be a non-empty string." -f $StepName),
'Workflow'
)
}

# PreconditionEvent.Data is optional but must be a hashtable if present.
if ($rawPreconditionEvent.Contains('Data') -and $null -ne $rawPreconditionEvent['Data']) {
if ($rawPreconditionEvent['Data'] -isnot [System.Collections.IDictionary]) {
throw [System.ArgumentException]::new(
("Workflow step '{0}': PreconditionEvent.Data must be a hashtable." -f $StepName),
'Workflow'
)
}
}

$normalized.PreconditionEvent = Copy-IdleDataObject -Value $rawPreconditionEvent
}
}

return $normalized
}
Loading