From 99a7f25476b1b4e86b9a7fbb709f68385cd49b68 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:21:10 +0100 Subject: [PATCH 01/15] core: add Export-IdlePlanObject public core cmdlet - will be wrapped in Idle\Public --- src/IdLE.Core/IdLE.Core.psd1 | 3 +- .../Public/Export-IdlePlanObject.ps1 | 108 ++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/IdLE.Core/Public/Export-IdlePlanObject.ps1 diff --git a/src/IdLE.Core/IdLE.Core.psd1 b/src/IdLE.Core/IdLE.Core.psd1 index d72610ca..d59d78e7 100644 --- a/src/IdLE.Core/IdLE.Core.psd1 +++ b/src/IdLE.Core/IdLE.Core.psd1 @@ -11,7 +11,8 @@ 'New-IdleLifecycleRequestObject', 'Test-IdleWorkflowDefinitionObject', 'New-IdlePlanObject', - 'Invoke-IdlePlanObject' + 'Invoke-IdlePlanObject', + 'Export-IdlePlanObject' ) CmdletsToExport = @() AliasesToExport = @() diff --git a/src/IdLE.Core/Public/Export-IdlePlanObject.ps1 b/src/IdLE.Core/Public/Export-IdlePlanObject.ps1 new file mode 100644 index 00000000..b929218b --- /dev/null +++ b/src/IdLE.Core/Public/Export-IdlePlanObject.ps1 @@ -0,0 +1,108 @@ +<# +.SYNOPSIS +Exports an IdLE LifecyclePlan as a canonical, machine-readable JSON artifact. + +.DESCRIPTION +Exports a LifecyclePlan to the **canonical JSON contract** defined by IdLE. +The output is intended for auditing, approvals, CI checks, and host integrations. + +By default, the cmdlet returns a **pretty-printed JSON string**. If -Path is provided, +the JSON is written to disk as UTF-8 (no BOM). Use -PassThru to also return the JSON string +when writing a file. + +This cmdlet is part of IdLE.Core and must remain host-agnostic. + +.PARAMETER Plan +The LifecyclePlan object to export. Accepts pipeline input. + +.PARAMETER Path +Optional file path to write the JSON artifact to. + +.PARAMETER PassThru +When -Path is used, returns the JSON string in addition to writing the file. + +.EXAMPLE +$plan = New-IdlePlanObject -Request $request -Workflow $workflow -StepRegistry $registry +$plan | Export-IdlePlanObject + +Exports the plan and returns the JSON string. + +.EXAMPLE +New-IdlePlanObject -Request $request -Workflow $workflow -StepRegistry $registry | + Export-IdlePlanObject -Path ./artifacts/plan.json + +Exports the plan and writes the JSON to a file. + +.EXAMPLE +New-IdlePlanObject -Request $request -Workflow $workflow -StepRegistry $registry | + Export-IdlePlanObject -Path ./artifacts/plan.json -PassThru + +Writes the file and also returns the JSON string. + +.OUTPUTS +System.String +#> +function Export-IdlePlanObject { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [ValidateNotNull()] + [object] $Plan, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Path, + + [Parameter()] + [switch] $PassThru + ) + + begin { + # Keep JSON output stable and review-friendly. + # Depth must be sufficient for nested step inputs/expectedState. + $jsonDepth = 20 + + # Prefer UTF-8 without BOM for deterministic artifacts across platforms. + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + } + + process { + # Map internal plan object into a stable export DTO (pure data). + # NOTE: ConvertTo-IdlePlanExportObject is implemented as a private function in IdLE.Core. + $exportObject = ConvertTo-IdlePlanExportObject -Plan $Plan + + # Pretty-printed JSON by default (no -Compress). + $json = $exportObject | ConvertTo-Json -Depth $jsonDepth + + if (-not [string]::IsNullOrWhiteSpace($Path)) { + # Resolve to a full path early to avoid surprises and to improve error messages. + $resolvedPath = $Path + + try { + # If the parent directory does not exist, fail with a clear message. + $parent = Split-Path -Path $resolvedPath -Parent + if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path -LiteralPath $parent)) { + $message = "The output directory does not exist: '{0}'." -f $parent + throw [System.IO.DirectoryNotFoundException]::new($message) + } + + # Write JSON deterministically. ConvertTo-Json uses LF on PowerShell Core, and + # WriteAllText will preserve the string's newlines as-is. + [System.IO.File]::WriteAllText($resolvedPath, $json, $utf8NoBom) + } + catch { + $message = "Failed to write plan export JSON to '{0}'. {1}" -f $resolvedPath, $_.Exception.Message + throw [System.IO.IOException]::new($message, $_.Exception) + } + + if ($PassThru) { + return $json + } + + return + } + + return $json + } +} From 3e8547e3dabca8028c545a198e17dda8183c47ba Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:22:56 +0100 Subject: [PATCH 02/15] idle: add Export-IdlePlan wrapper cmdlet --- src/IdLE/IdLE.psd1 | 3 +- src/IdLE/IdLE.psm1 | 3 +- src/IdLE/Public/Export-IdlePlan.ps1 | 77 +++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 src/IdLE/Public/Export-IdlePlan.ps1 diff --git a/src/IdLE/IdLE.psd1 b/src/IdLE/IdLE.psd1 index 86187a45..1d7e2ff1 100644 --- a/src/IdLE/IdLE.psd1 +++ b/src/IdLE/IdLE.psd1 @@ -13,7 +13,8 @@ 'Test-IdleWorkflow', 'New-IdleLifecycleRequest', 'New-IdlePlan', - 'Invoke-IdlePlan' + 'Invoke-IdlePlan', + 'Export-IdlePlan' ) CmdletsToExport = @() AliasesToExport = @() diff --git a/src/IdLE/IdLE.psm1 b/src/IdLE/IdLE.psm1 index 603a1ce5..3bbc9e43 100644 --- a/src/IdLE/IdLE.psm1 +++ b/src/IdLE/IdLE.psm1 @@ -52,5 +52,6 @@ Export-ModuleMember -Function @( 'Test-IdleWorkflow', 'New-IdleLifecycleRequest', 'New-IdlePlan', - 'Invoke-IdlePlan' + 'Invoke-IdlePlan', + 'Export-IdlePlan' ) diff --git a/src/IdLE/Public/Export-IdlePlan.ps1 b/src/IdLE/Public/Export-IdlePlan.ps1 new file mode 100644 index 00000000..18ae39c2 --- /dev/null +++ b/src/IdLE/Public/Export-IdlePlan.ps1 @@ -0,0 +1,77 @@ +<# +.SYNOPSIS +Exports an IdLE LifecyclePlan as a canonical JSON artifact. + +.DESCRIPTION +This cmdlet is the **user-facing** wrapper exposed by the IdLE meta module. + +It delegates to IdLE.Core's `Export-IdlePlanObject`, which implements the canonical +plan export contract. + +By default, the cmdlet returns a pretty-printed JSON string. If -Path is provided, +the JSON is written to disk as UTF-8 (no BOM). Use -PassThru to also return the JSON +string when writing a file. + +.PARAMETER Plan +The LifecyclePlan object to export. Accepts pipeline input. + +.PARAMETER Path +Optional file path to write the JSON artifact to. + +.PARAMETER PassThru +When -Path is used, returns the JSON string in addition to writing the file. + +.EXAMPLE +$plan = New-IdlePlan -Request $request -Workflow $workflow -StepRegistry $registry +$plan | Export-IdlePlan + +Exports the plan and returns the JSON string. + +.EXAMPLE +New-IdlePlan -Request $request -Workflow $workflow -StepRegistry $registry | + Export-IdlePlan -Path ./artifacts/plan.json + +Exports the plan and writes the JSON to a file. + +.EXAMPLE +New-IdlePlan -Request $request -Workflow $workflow -StepRegistry $registry | + Export-IdlePlan -Path ./artifacts/plan.json -PassThru + +Writes the file and also returns the JSON string. + +.OUTPUTS +System.String +#> +function Export-IdlePlan { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [ValidateNotNull()] + [object] $Plan, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Path, + + [Parameter()] + [switch] $PassThru + ) + + process { + # Delegate to IdLE.Core to ensure the canonical contract is implemented in one place. + $params = @{ + Plan = $Plan + } + + if (-not [string]::IsNullOrWhiteSpace($Path)) { + $params.Path = $Path + } + + if ($PassThru) { + $params.PassThru = $true + } + + return IdLE.Core\Export-IdlePlanObject @params + } +} From aaa96e4713bbe89986c2b6118f43efb93f606d8e Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:24:38 +0100 Subject: [PATCH 03/15] core: private DTO export creator for plan --- .../ConvertTo-IdlePlanExportObject.ps1 | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 new file mode 100644 index 00000000..2589a6e6 --- /dev/null +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -0,0 +1,200 @@ +<# +.SYNOPSIS +Maps an internal LifecyclePlan object to the canonical Plan Export contract DTO. + +.DESCRIPTION +This is the single source of truth for the Plan Export JSON contract mapping. +It produces a pure data object (ordered hashtables / PSCustomObject compatible) that can be +serialized to JSON deterministically. + +The mapping is intentionally defensive: +- It supports multiple plausible internal property names (to reduce coupling to internal refactors). +- It does not depend on host/runtime-specific objects. +- It never emits executable PowerShell objects (script blocks, delegates, etc.). + +The JSON serializer (ConvertTo-Json) is called by the public cmdlet. +#> +function ConvertTo-IdlePlanExportObject { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNull()] + [object] $Plan + ) + + function Get-FirstPropertyValue { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object] $Object, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string[]] $Names + ) + + foreach ($name in $Names) { + $prop = $Object.PSObject.Properties[$name] + if ($null -ne $prop) { + return $prop.Value + } + } + + return $null + } + + function New-OrderedMap { + [CmdletBinding()] + param() + + return [ordered] @{} + } + + function ConvertTo-Iso8601UtcString { + [CmdletBinding()] + param( + [Parameter()] + [object] $Value + ) + + if ($null -eq $Value) { + return $null + } + + # Accept DateTime / DateTimeOffset; otherwise keep as-is (string) to avoid lossy coercion. + if ($Value -is [datetime]) { + return ([datetime]::SpecifyKind($Value, [DateTimeKind]::Utc)).ToString("o") + } + + if ($Value -is [DateTimeOffset]) { + return $Value.ToUniversalTime().ToString("o") + } + + return [string] $Value + } + + # ---- Engine block -------------------------------------------------------- + # We expose engine name/version for informational purposes only. + $engineName = 'IdLE' + $engineVersion = $null + + # Prefer module version if available; otherwise leave null (the contract version is schemaVersion). + $moduleVersion = $MyInvocation.MyCommand.Module.Version + if ($null -ne $moduleVersion) { + $engineVersion = [string] $moduleVersion + } + + # ---- Request block ------------------------------------------------------- + $request = Get-FirstPropertyValue -Object $Plan -Names @('Request', 'LifecycleRequest', 'InputRequest') + + $requestType = $null + $correlationId = $null + $actor = $null + $requestInput = $null + + if ($null -ne $request) { + $requestType = Get-FirstPropertyValue -Object $request -Names @('Type', 'RequestType', 'LifecycleType', 'Kind') + $correlationId = Get-FirstPropertyValue -Object $request -Names @('CorrelationId', 'CorrelationID', 'Correlation', 'Id') + $actor = Get-FirstPropertyValue -Object $request -Names @('Actor', 'RequestedBy', 'Source', 'Origin') + + # Keep input opaque. We do not transform or validate here. + $requestInput = Get-FirstPropertyValue -Object $request -Names @('Input', 'Data', 'Payload', 'Attributes') + } + + $requestMap = New-OrderedMap + $requestMap.type = $requestType + $requestMap.correlationId = $correlationId + $requestMap.actor = $actor + $requestMap.input = $requestInput + + # ---- Plan block ---------------------------------------------------------- + $planId = Get-FirstPropertyValue -Object $Plan -Names @('Id', 'PlanId', 'PlanID', 'CorrelationId') + $createdAt = Get-FirstPropertyValue -Object $Plan -Names @('CreatedAt', 'CreatedOn', 'Timestamp', 'PlannedAt') + $mode = Get-FirstPropertyValue -Object $Plan -Names @('Mode', 'State', 'Status') + + $steps = Get-FirstPropertyValue -Object $Plan -Names @('Steps', 'Items', 'PlanSteps', 'Entries') + if ($null -eq $steps) { + $steps = @() + } + + $stepList = @() + $index = 0 + foreach ($step in $steps) { + $index++ + + if ($null -eq $step) { + continue + } + + $stepId = Get-FirstPropertyValue -Object $step -Names @('Id', 'StepId', 'StepID') + if ([string]::IsNullOrWhiteSpace([string] $stepId)) { + # Use a deterministic fallback id when none exists. + $stepId = ('step-{0:00}' -f $index) + } + + $stepName = Get-FirstPropertyValue -Object $step -Names @('Name', 'DisplayName', 'Title') + $stepType = Get-FirstPropertyValue -Object $step -Names @('StepType', 'Type', 'Kind') + $provider = Get-FirstPropertyValue -Object $step -Names @('Provider', 'ProviderName', 'Adapter', 'Target') + + # Conditions: export as declarative object, without evaluation. + $condition = Get-FirstPropertyValue -Object $step -Names @('Condition', 'When', 'Applicability', 'Guard') + + $conditionMap = $null + if ($null -ne $condition) { + $conditionType = Get-FirstPropertyValue -Object $condition -Names @('Type', 'Kind') + $expression = Get-FirstPropertyValue -Object $condition -Names @('Expression', 'Expr', 'Query') + + $conditionMap = New-OrderedMap + $conditionMap.type = $conditionType + $conditionMap.expression = $expression + } + else { + # If no condition exists, represent unconditional applicability explicitly. + $conditionMap = New-OrderedMap + $conditionMap.type = 'always' + $conditionMap.expression = $null + } + + # Inputs and expected state are treated as opaque, pure data. + $inputs = Get-FirstPropertyValue -Object $step -Names @('Inputs', 'Input', 'Parameters', 'Arguments') + $expectedState = Get-FirstPropertyValue -Object $step -Names @('ExpectedState', 'DesiredState', 'TargetState', 'State') + + $stepMap = New-OrderedMap + $stepMap.id = $stepId + $stepMap.name = $stepName + $stepMap.stepType = $stepType + $stepMap.provider = $provider + $stepMap.condition = $conditionMap + $stepMap.inputs = $inputs + $stepMap.expectedState = $expectedState + + $stepList += $stepMap + } + + $planMap = New-OrderedMap + $planMap.id = $planId + $planMap.createdAt = ConvertTo-Iso8601UtcString -Value $createdAt + $planMap.mode = $mode + $planMap.steps = $stepList + + # ---- Metadata block ------------------------------------------------------ + # Metadata is optional and must not carry engine semantics. + $metadata = New-OrderedMap + $metadata.generatedBy = 'Export-IdlePlanObject' + $metadata.environment = $null + $metadata.labels = @() + + # ---- Root --------------------------------------------------------------- + $engineMap = New-OrderedMap + $engineMap.name = $engineName + $engineMap.version = $engineVersion + + $root = New-OrderedMap + $root.schemaVersion = '1.0' + $root.engine = $engineMap + $root.request = $requestMap + $root.plan = $planMap + $root.metadata = $metadata + + return $root +} From 489daf8de55c307ee29e8d99778747e7c7c6aed0 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:38:37 +0100 Subject: [PATCH 04/15] tests: added Export-IdlePlan test with expected fixture --- tests/Export-IdlePlan.Tests.ps1 | 104 ++++++++++++++++++ .../plan-export/expected/plan-export.json | 38 +++++++ 2 files changed, 142 insertions(+) create mode 100644 tests/Export-IdlePlan.Tests.ps1 create mode 100644 tests/fixtures/plan-export/expected/plan-export.json diff --git a/tests/Export-IdlePlan.Tests.ps1 b/tests/Export-IdlePlan.Tests.ps1 new file mode 100644 index 00000000..d70f501f --- /dev/null +++ b/tests/Export-IdlePlan.Tests.ps1 @@ -0,0 +1,104 @@ +# Requires -Version 7.0 +Set-StrictMode -Version Latest + +BeforeAll { + . (Join-Path $PSScriptRoot '_testHelpers.ps1') + Import-IdleTestModule +} + +Describe 'Export-IdlePlan' { + + Context 'JSON contract export' { + + It 'exports a stable, canonical JSON representation of a plan' { + # Arrange + $cid = '11111111-1111-1111-1111-111111111111' + + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-export.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Export Fixture' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'Ensure Mailbox' + Type = 'EnsureMailbox' + With = @{ + mailboxType = 'User' + } + } + ) +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -CorrelationId $cid + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + + $expectedPath = Join-Path $PSScriptRoot 'fixtures/plan-export/expected/plan-export.json' + $expectedJson = Get-Content -Path $expectedPath -Raw -Encoding utf8 + + # Act + $actualJson = $plan | Export-IdlePlan + + # Assert + $actualJson | Should -Be $expectedJson + } + } + + Context 'File output (-Path)' { + + It 'writes the JSON artifact to disk (TestDrive) using UTF-8 without BOM' { + # Arrange + $cid = '11111111-1111-1111-1111-111111111111' + + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-export-empty.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Export Fixture Empty' + LifecycleEvent = 'Joiner' + Steps = @() +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -CorrelationId $cid + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + + $outFile = Join-Path $TestDrive 'plan.json' + + # Act + $null = $plan | Export-IdlePlan -Path $outFile + + # Assert + Test-Path -LiteralPath $outFile | Should -BeTrue + + $content = Get-Content -Path $outFile -Raw -Encoding utf8 + $content | Should -Not -BeNullOrEmpty + } + } + + Context 'Contract invariants' { + + It 'always includes schemaVersion 1.0' { + # Arrange + $cid = '11111111-1111-1111-1111-111111111111' + + $wfPath = Join-Path -Path $TestDrive -ChildPath 'joiner-export-empty.psd1' + Set-Content -Path $wfPath -Encoding UTF8 -Value @' +@{ + Name = 'Joiner - Export Fixture Empty' + LifecycleEvent = 'Joiner' + Steps = @() +} +'@ + + $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -CorrelationId $cid + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } + + # Act + $json = $plan | Export-IdlePlan | ConvertFrom-Json + + # Assert + $json.schemaVersion | Should -Be '1.0' + } + } +} diff --git a/tests/fixtures/plan-export/expected/plan-export.json b/tests/fixtures/plan-export/expected/plan-export.json new file mode 100644 index 00000000..6ed8557e --- /dev/null +++ b/tests/fixtures/plan-export/expected/plan-export.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": "1.0", + "engine": { + "name": "IdLE" + }, + "request": { + "type": "Joiner", + "correlationId": "11111111-1111-1111-1111-111111111111", + "actor": null, + "input": null + }, + "plan": { + "id": "11111111-1111-1111-1111-111111111111", + "createdAt": null, + "mode": null, + "steps": [ + { + "id": "step-01", + "name": "Ensure Mailbox", + "stepType": "EnsureMailbox", + "provider": null, + "condition": { + "type": "always", + "expression": null + }, + "inputs": { + "mailboxType": "User" + }, + "expectedState": null + } + ] + }, + "metadata": { + "generatedBy": "Export-IdlePlanObject", + "environment": null, + "labels": [] + } +} From f400d9f2368410ecc830ea8ffbe3085bf29c4c17 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:39:15 +0100 Subject: [PATCH 05/15] core: remove engineversion to avoid version bumps required for tests / expected fixtures --- .../Private/ConvertTo-IdlePlanExportObject.ps1 | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index 2589a6e6..bde4a5a0 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -63,26 +63,20 @@ function ConvertTo-IdlePlanExportObject { # Accept DateTime / DateTimeOffset; otherwise keep as-is (string) to avoid lossy coercion. if ($Value -is [datetime]) { - return ([datetime]::SpecifyKind($Value, [DateTimeKind]::Utc)).ToString("o") + return ([datetime]::SpecifyKind($Value, [DateTimeKind]::Utc)).ToString('o') } if ($Value -is [DateTimeOffset]) { - return $Value.ToUniversalTime().ToString("o") + return $Value.ToUniversalTime().ToString('o') } return [string] $Value } # ---- Engine block -------------------------------------------------------- - # We expose engine name/version for informational purposes only. + # Export engine name only. Engine version is intentionally omitted to keep the artifact stable + # across module version bumps. Contract versioning is done via schemaVersion. $engineName = 'IdLE' - $engineVersion = $null - - # Prefer module version if available; otherwise leave null (the contract version is schemaVersion). - $moduleVersion = $MyInvocation.MyCommand.Module.Version - if ($null -ne $moduleVersion) { - $engineVersion = [string] $moduleVersion - } # ---- Request block ------------------------------------------------------- $request = Get-FirstPropertyValue -Object $Plan -Names @('Request', 'LifecycleRequest', 'InputRequest') @@ -156,7 +150,7 @@ function ConvertTo-IdlePlanExportObject { } # Inputs and expected state are treated as opaque, pure data. - $inputs = Get-FirstPropertyValue -Object $step -Names @('Inputs', 'Input', 'Parameters', 'Arguments') + $inputs = Get-FirstPropertyValue -Object $step -Names @('Inputs', 'Input', 'Parameters', 'Arguments', 'With') $expectedState = Get-FirstPropertyValue -Object $step -Names @('ExpectedState', 'DesiredState', 'TargetState', 'State') $stepMap = New-OrderedMap @@ -187,7 +181,6 @@ function ConvertTo-IdlePlanExportObject { # ---- Root --------------------------------------------------------------- $engineMap = New-OrderedMap $engineMap.name = $engineName - $engineMap.version = $engineVersion $root = New-OrderedMap $root.schemaVersion = '1.0' From 7d5481fa6ac24b752236ef952bccf18b0c475ba6 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:49:53 +0100 Subject: [PATCH 06/15] tests: createdAt removed from Export-IdlePlan.Tests --- .../ConvertTo-IdlePlanExportObject.ps1 | 84 ++++++++----------- .../plan-export/expected/plan-export.json | 1 - 2 files changed, 34 insertions(+), 51 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index bde4a5a0..13f0c42c 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -4,15 +4,16 @@ Maps an internal LifecyclePlan object to the canonical Plan Export contract DTO. .DESCRIPTION This is the single source of truth for the Plan Export JSON contract mapping. -It produces a pure data object (ordered hashtables / PSCustomObject compatible) that can be -serialized to JSON deterministically. +It produces a pure data object (ordered hashtables) that can be serialized to JSON +deterministically. -The mapping is intentionally defensive: -- It supports multiple plausible internal property names (to reduce coupling to internal refactors). -- It does not depend on host/runtime-specific objects. -- It never emits executable PowerShell objects (script blocks, delegates, etc.). +Notes: +- Engine version is intentionally omitted to avoid noise on module version bumps. +- Plan timestamps are intentionally omitted to keep Golden/Snapshot tests stable. + Contract versioning is done via schemaVersion. -The JSON serializer (ConvertTo-Json) is called by the public cmdlet. +The mapping is defensive and accepts multiple internal property names to reduce coupling +to internal refactors. #> function ConvertTo-IdlePlanExportObject { [CmdletBinding()] @@ -50,35 +51,13 @@ function ConvertTo-IdlePlanExportObject { return [ordered] @{} } - function ConvertTo-Iso8601UtcString { - [CmdletBinding()] - param( - [Parameter()] - [object] $Value - ) - - if ($null -eq $Value) { - return $null - } - - # Accept DateTime / DateTimeOffset; otherwise keep as-is (string) to avoid lossy coercion. - if ($Value -is [datetime]) { - return ([datetime]::SpecifyKind($Value, [DateTimeKind]::Utc)).ToString('o') - } - - if ($Value -is [DateTimeOffset]) { - return $Value.ToUniversalTime().ToString('o') - } - - return [string] $Value - } - # ---- Engine block -------------------------------------------------------- - # Export engine name only. Engine version is intentionally omitted to keep the artifact stable - # across module version bumps. Contract versioning is done via schemaVersion. - $engineName = 'IdLE' + # Export engine name only. Contract versioning is done via schemaVersion. + $engineMap = New-OrderedMap + $engineMap.name = 'IdLE' # ---- Request block ------------------------------------------------------- + # Prefer an explicit request object if present. Otherwise, fall back to plan fields. $request = Get-FirstPropertyValue -Object $Plan -Names @('Request', 'LifecycleRequest', 'InputRequest') $requestType = $null @@ -87,13 +66,20 @@ function ConvertTo-IdlePlanExportObject { $requestInput = $null if ($null -ne $request) { - $requestType = Get-FirstPropertyValue -Object $request -Names @('Type', 'RequestType', 'LifecycleType', 'Kind') + $requestType = Get-FirstPropertyValue -Object $request -Names @('Type', 'RequestType', 'LifecycleType', 'Kind', 'LifecycleEvent') $correlationId = Get-FirstPropertyValue -Object $request -Names @('CorrelationId', 'CorrelationID', 'Correlation', 'Id') $actor = Get-FirstPropertyValue -Object $request -Names @('Actor', 'RequestedBy', 'Source', 'Origin') # Keep input opaque. We do not transform or validate here. $requestInput = Get-FirstPropertyValue -Object $request -Names @('Input', 'Data', 'Payload', 'Attributes') } + else { + # Plan-shaped fallback (current IdLE plan object shape). + $requestType = Get-FirstPropertyValue -Object $Plan -Names @('LifecycleEvent', 'Type', 'RequestType') + $correlationId = Get-FirstPropertyValue -Object $Plan -Names @('CorrelationId', 'CorrelationID', 'Id', 'PlanId', 'PlanID') + $actor = Get-FirstPropertyValue -Object $Plan -Names @('Actor', 'RequestedBy') + $requestInput = $null + } $requestMap = New-OrderedMap $requestMap.type = $requestType @@ -102,10 +88,11 @@ function ConvertTo-IdlePlanExportObject { $requestMap.input = $requestInput # ---- Plan block ---------------------------------------------------------- - $planId = Get-FirstPropertyValue -Object $Plan -Names @('Id', 'PlanId', 'PlanID', 'CorrelationId') - $createdAt = Get-FirstPropertyValue -Object $Plan -Names @('CreatedAt', 'CreatedOn', 'Timestamp', 'PlannedAt') - $mode = Get-FirstPropertyValue -Object $Plan -Names @('Mode', 'State', 'Status') + # Keep plan id stable and aligned with the internal plan identity. + $planId = Get-FirstPropertyValue -Object $Plan -Names @('Id', 'PlanId', 'PlanID', 'CorrelationId', 'CorrelationID') + $mode = Get-FirstPropertyValue -Object $Plan -Names @('Mode', 'State', 'Status') + # Plan timestamps are intentionally omitted for contract stability (Golden tests). $steps = Get-FirstPropertyValue -Object $Plan -Names @('Steps', 'Items', 'PlanSteps', 'Entries') if ($null -eq $steps) { $steps = @() @@ -113,6 +100,7 @@ function ConvertTo-IdlePlanExportObject { $stepList = @() $index = 0 + foreach ($step in $steps) { $index++ @@ -122,7 +110,7 @@ function ConvertTo-IdlePlanExportObject { $stepId = Get-FirstPropertyValue -Object $step -Names @('Id', 'StepId', 'StepID') if ([string]::IsNullOrWhiteSpace([string] $stepId)) { - # Use a deterministic fallback id when none exists. + # Deterministic fallback id when none exists. $stepId = ('step-{0:00}' -f $index) } @@ -130,7 +118,8 @@ function ConvertTo-IdlePlanExportObject { $stepType = Get-FirstPropertyValue -Object $step -Names @('StepType', 'Type', 'Kind') $provider = Get-FirstPropertyValue -Object $step -Names @('Provider', 'ProviderName', 'Adapter', 'Target') - # Conditions: export as declarative object, without evaluation. + # Conditions: export declaratively, without evaluation. + # Current plan object shows Condition = $null, so we default to "always". $condition = Get-FirstPropertyValue -Object $step -Names @('Condition', 'When', 'Applicability', 'Guard') $conditionMap = $null @@ -143,13 +132,13 @@ function ConvertTo-IdlePlanExportObject { $conditionMap.expression = $expression } else { - # If no condition exists, represent unconditional applicability explicitly. $conditionMap = New-OrderedMap $conditionMap.type = 'always' $conditionMap.expression = $null } # Inputs and expected state are treated as opaque, pure data. + # Current plan uses 'With' for inputs. $inputs = Get-FirstPropertyValue -Object $step -Names @('Inputs', 'Input', 'Parameters', 'Arguments', 'With') $expectedState = Get-FirstPropertyValue -Object $step -Names @('ExpectedState', 'DesiredState', 'TargetState', 'State') @@ -167,27 +156,22 @@ function ConvertTo-IdlePlanExportObject { $planMap = New-OrderedMap $planMap.id = $planId - $planMap.createdAt = ConvertTo-Iso8601UtcString -Value $createdAt $planMap.mode = $mode $planMap.steps = $stepList # ---- Metadata block ------------------------------------------------------ - # Metadata is optional and must not carry engine semantics. - $metadata = New-OrderedMap - $metadata.generatedBy = 'Export-IdlePlanObject' - $metadata.environment = $null - $metadata.labels = @() + $metadataMap = New-OrderedMap + $metadataMap.generatedBy = 'Export-IdlePlanObject' + $metadataMap.environment = $null + $metadataMap.labels = @() # ---- Root --------------------------------------------------------------- - $engineMap = New-OrderedMap - $engineMap.name = $engineName - $root = New-OrderedMap $root.schemaVersion = '1.0' $root.engine = $engineMap $root.request = $requestMap $root.plan = $planMap - $root.metadata = $metadata + $root.metadata = $metadataMap return $root } diff --git a/tests/fixtures/plan-export/expected/plan-export.json b/tests/fixtures/plan-export/expected/plan-export.json index 6ed8557e..1325fe2b 100644 --- a/tests/fixtures/plan-export/expected/plan-export.json +++ b/tests/fixtures/plan-export/expected/plan-export.json @@ -11,7 +11,6 @@ }, "plan": { "id": "11111111-1111-1111-1111-111111111111", - "createdAt": null, "mode": null, "steps": [ { From bd4b51b4f542cef11ccadebe24a2aa2fe7f2b726 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:50:03 +0100 Subject: [PATCH 07/15] docs: updated export-plan specs --- docs/specs/plan-export.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/specs/plan-export.md b/docs/specs/plan-export.md index cfd4da68..97051d76 100644 --- a/docs/specs/plan-export.md +++ b/docs/specs/plan-export.md @@ -43,8 +43,7 @@ The plan export MUST NOT contain: { "schemaVersion": "1.0", "engine": { - "name": "IdLE", - "version": "0.4.0" + "name": "IdLE" }, "request": { }, "plan": { }, @@ -57,6 +56,19 @@ The plan export MUST NOT contain: Version of this JSON schema (this contract). Independent from the IdLE engine version. +### engine + +Identifies the engine that produced the exported plan. +The engine object is informational only and MUST NOT be used for contract compatibility decisions. + +- engine.name is required and identifies the producing engine (e.g. IdLE). +- engine.version is intentionally omitted in this specification. + +The engine version is not part of the contract to ensure stable, deterministic exports across engine version bumps. +Contract compatibility and evolution are tracked exclusively via schemaVersion. + +Hosts that require engine build or release information SHOULD attach it as external metadata outside of the exported plan artifact. + --- ## Request Object @@ -87,7 +99,6 @@ Rules: ```json "plan": { "id": "plan-001", - "createdAt": "2025-01-01T10:15:00Z", "mode": "PlanOnly", "steps": [] } @@ -98,7 +109,7 @@ Rules: | Field | Description | | ------ | ------------ | | id | Unique identifier of the plan | -| createdAt | ISO-8601 UTC timestamp | +| createdAt | (Optional) ISO-8601 UTC timestamp | | mode | Plan lifecycle state | | steps | Ordered list of step objects | @@ -207,6 +218,7 @@ The engine MUST NOT rely on metadata semantics. - LF line endings - Pretty-printed JSON - Stable property ordering +- createdAt MAY be omitted for deterministic exports. --- From a13e7b822d430202b62cdb8340a15e8e20f7abd0 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:53:16 +0100 Subject: [PATCH 08/15] tests: fixed export of Export-IdlePlanObject + added to Export-IdlePlan to expected public API surface --- src/IdLE.Core/IdLE.Core.psm1 | 3 ++- tests/ModuleSurface.Tests.ps1 | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/IdLE.Core/IdLE.Core.psm1 b/src/IdLE.Core/IdLE.Core.psm1 index 2fa82a27..ba22c46d 100644 --- a/src/IdLE.Core/IdLE.Core.psm1 +++ b/src/IdLE.Core/IdLE.Core.psm1 @@ -22,5 +22,6 @@ Export-ModuleMember -Function @( 'New-IdleLifecycleRequestObject', 'Test-IdleWorkflowDefinitionObject', 'New-IdlePlanObject', - 'Invoke-IdlePlanObject' + 'Invoke-IdlePlanObject', + 'Export-IdlePlanObject' ) -Alias @() diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 index 41734efc..10407208 100644 --- a/tests/ModuleSurface.Tests.ps1 +++ b/tests/ModuleSurface.Tests.ps1 @@ -28,6 +28,7 @@ Describe 'Module manifests and public surface' { 'New-IdleLifecycleRequest' 'New-IdlePlan' 'Test-IdleWorkflow' + 'Export-IdlePlan' ) | Sort-Object $actual = (Get-Command -Module IdLE).Name | Sort-Object From 9955eb512beb887a16a987abe62cf3e6002b1a28 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 19:02:49 +0100 Subject: [PATCH 09/15] tests: fixing / stablelize line end and null attributes in Export-IdlePlan.Test --- .../ConvertTo-IdlePlanExportObject.ps1 | 120 +++++++++++++----- tests/Export-IdlePlan.Tests.ps1 | 2 +- 2 files changed, 87 insertions(+), 35 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index 13f0c42c..fe11b908 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -3,16 +3,16 @@ Maps an internal LifecyclePlan object to the canonical Plan Export contract DTO. .DESCRIPTION -This is the single source of truth for the Plan Export JSON contract mapping. +This function is the single source of truth for the Plan Export JSON contract mapping. It produces a pure data object (ordered hashtables) that can be serialized to JSON deterministically. -Notes: -- Engine version is intentionally omitted to avoid noise on module version bumps. -- Plan timestamps are intentionally omitted to keep Golden/Snapshot tests stable. - Contract versioning is done via schemaVersion. +Contract stability decisions: +- engine.version is intentionally omitted (avoid noise on module version bumps) +- plan.createdAt is intentionally omitted (avoid non-deterministic timestamps in exports) +- empty strings are normalized to $null for identifier-like fields (e.g., actor) -The mapping is defensive and accepts multiple internal property names to reduce coupling +The mapping is defensive and supports multiple internal property names to reduce coupling to internal refactors. #> function ConvertTo-IdlePlanExportObject { @@ -23,6 +23,12 @@ function ConvertTo-IdlePlanExportObject { [object] $Plan ) + function New-OrderedMap { + [CmdletBinding()] + param() + return [ordered] @{} + } + function Get-FirstPropertyValue { [CmdletBinding()] param( @@ -44,15 +50,25 @@ function ConvertTo-IdlePlanExportObject { return $null } - function New-OrderedMap { + function ConvertTo-NullIfEmptyString { [CmdletBinding()] - param() + param( + [Parameter()] + [object] $Value + ) - return [ordered] @{} + if ($null -eq $Value) { + return $null + } + + if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + return $Value } # ---- Engine block -------------------------------------------------------- - # Export engine name only. Contract versioning is done via schemaVersion. $engineMap = New-OrderedMap $engineMap.name = 'IdLE' @@ -66,19 +82,36 @@ function ConvertTo-IdlePlanExportObject { $requestInput = $null if ($null -ne $request) { - $requestType = Get-FirstPropertyValue -Object $request -Names @('Type', 'RequestType', 'LifecycleType', 'Kind', 'LifecycleEvent') - $correlationId = Get-FirstPropertyValue -Object $request -Names @('CorrelationId', 'CorrelationID', 'Correlation', 'Id') - $actor = Get-FirstPropertyValue -Object $request -Names @('Actor', 'RequestedBy', 'Source', 'Origin') + $requestType = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $request -Names @('Type', 'RequestType', 'LifecycleType', 'Kind', 'LifecycleEvent') + ) + + $correlationId = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $request -Names @('CorrelationId', 'CorrelationID', 'Correlation', 'Id') + ) + + $actor = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $request -Names @('Actor', 'RequestedBy', 'Source', 'Origin') + ) # Keep input opaque. We do not transform or validate here. - $requestInput = Get-FirstPropertyValue -Object $request -Names @('Input', 'Data', 'Payload', 'Attributes') + $requestInput = Get-FirstPropertyValue -Object $request -Names @('Input', 'Data', 'Payload', 'Attributes') } else { # Plan-shaped fallback (current IdLE plan object shape). - $requestType = Get-FirstPropertyValue -Object $Plan -Names @('LifecycleEvent', 'Type', 'RequestType') - $correlationId = Get-FirstPropertyValue -Object $Plan -Names @('CorrelationId', 'CorrelationID', 'Id', 'PlanId', 'PlanID') - $actor = Get-FirstPropertyValue -Object $Plan -Names @('Actor', 'RequestedBy') - $requestInput = $null + $requestType = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $Plan -Names @('LifecycleEvent', 'Type', 'RequestType') + ) + + $correlationId = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $Plan -Names @('CorrelationId', 'CorrelationID', 'Id', 'PlanId', 'PlanID') + ) + + $actor = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $Plan -Names @('Actor', 'RequestedBy') + ) + + $requestInput = $null } $requestMap = New-OrderedMap @@ -88,11 +121,16 @@ function ConvertTo-IdlePlanExportObject { $requestMap.input = $requestInput # ---- Plan block ---------------------------------------------------------- - # Keep plan id stable and aligned with the internal plan identity. - $planId = Get-FirstPropertyValue -Object $Plan -Names @('Id', 'PlanId', 'PlanID', 'CorrelationId', 'CorrelationID') - $mode = Get-FirstPropertyValue -Object $Plan -Names @('Mode', 'State', 'Status') + $planId = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $Plan -Names @('Id', 'PlanId', 'PlanID', 'CorrelationId', 'CorrelationID') + ) + + $mode = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $Plan -Names @('Mode', 'State', 'Status') + ) + + # plan.createdAt is intentionally omitted (non-deterministic in current implementation) - # Plan timestamps are intentionally omitted for contract stability (Golden tests). $steps = Get-FirstPropertyValue -Object $Plan -Names @('Steps', 'Items', 'PlanSteps', 'Entries') if ($null -eq $steps) { $steps = @() @@ -108,24 +146,38 @@ function ConvertTo-IdlePlanExportObject { continue } - $stepId = Get-FirstPropertyValue -Object $step -Names @('Id', 'StepId', 'StepID') + $stepId = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $step -Names @('Id', 'StepId', 'StepID') + ) + if ([string]::IsNullOrWhiteSpace([string] $stepId)) { # Deterministic fallback id when none exists. $stepId = ('step-{0:00}' -f $index) } - $stepName = Get-FirstPropertyValue -Object $step -Names @('Name', 'DisplayName', 'Title') - $stepType = Get-FirstPropertyValue -Object $step -Names @('StepType', 'Type', 'Kind') - $provider = Get-FirstPropertyValue -Object $step -Names @('Provider', 'ProviderName', 'Adapter', 'Target') + $stepName = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $step -Names @('Name', 'DisplayName', 'Title') + ) - # Conditions: export declaratively, without evaluation. - # Current plan object shows Condition = $null, so we default to "always". + $stepType = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $step -Names @('StepType', 'Type', 'Kind') + ) + + $provider = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $step -Names @('Provider', 'ProviderName', 'Adapter', 'Target') + ) + + # Conditions are exported declaratively without evaluation. $condition = Get-FirstPropertyValue -Object $step -Names @('Condition', 'When', 'Applicability', 'Guard') - $conditionMap = $null if ($null -ne $condition) { - $conditionType = Get-FirstPropertyValue -Object $condition -Names @('Type', 'Kind') - $expression = Get-FirstPropertyValue -Object $condition -Names @('Expression', 'Expr', 'Query') + $conditionType = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $condition -Names @('Type', 'Kind') + ) + + $expression = ConvertTo-NullIfEmptyString -Value ( + Get-FirstPropertyValue -Object $condition -Names @('Expression', 'Expr', 'Query') + ) $conditionMap = New-OrderedMap $conditionMap.type = $conditionType @@ -137,10 +189,10 @@ function ConvertTo-IdlePlanExportObject { $conditionMap.expression = $null } - # Inputs and expected state are treated as opaque, pure data. - # Current plan uses 'With' for inputs. + # Inputs and expectedState are treated as opaque, pure data. + # Current IdLE plan object shape uses 'With' for inputs. $inputs = Get-FirstPropertyValue -Object $step -Names @('Inputs', 'Input', 'Parameters', 'Arguments', 'With') - $expectedState = Get-FirstPropertyValue -Object $step -Names @('ExpectedState', 'DesiredState', 'TargetState', 'State') + $expectedState = Get-FirstPropertyValue -Object $step -Names @('ExpectedState', 'DesiredState', 'TargetState') $stepMap = New-OrderedMap $stepMap.id = $stepId diff --git a/tests/Export-IdlePlan.Tests.ps1 b/tests/Export-IdlePlan.Tests.ps1 index d70f501f..6f6e1948 100644 --- a/tests/Export-IdlePlan.Tests.ps1 +++ b/tests/Export-IdlePlan.Tests.ps1 @@ -41,7 +41,7 @@ Describe 'Export-IdlePlan' { $actualJson = $plan | Export-IdlePlan # Assert - $actualJson | Should -Be $expectedJson + $actualJson.TrimEnd() | Should -Be $expectedJson.TrimEnd() } } From 1350d4b0cfc7e1b172788913d6f78938cc1d9a59 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Wed, 31 Dec 2025 19:22:54 +0100 Subject: [PATCH 10/15] docs: updated cmdlet reference --- docs/reference/cmdlets.md | 1 + docs/reference/cmdlets/Export-IdlePlan.md | 131 ++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 docs/reference/cmdlets/Export-IdlePlan.md diff --git a/docs/reference/cmdlets.md b/docs/reference/cmdlets.md index f833319b..046e775c 100644 --- a/docs/reference/cmdlets.md +++ b/docs/reference/cmdlets.md @@ -7,6 +7,7 @@ This page links the generated per-cmdlet reference pages and includes their syno | Cmdlet | Synopsis | | --- | --- | +| [Export-IdlePlan](cmdlets/Export-IdlePlan.md) | Exports an IdLE LifecyclePlan as a canonical JSON artifact. | | [Invoke-IdlePlan](cmdlets/Invoke-IdlePlan.md) | Executes an IdLE plan. | | [New-IdleLifecycleRequest](cmdlets/New-IdleLifecycleRequest.md) | Creates a lifecycle request object. | | [New-IdlePlan](cmdlets/New-IdlePlan.md) | Creates a deterministic plan from a lifecycle request and a workflow definition. | diff --git a/docs/reference/cmdlets/Export-IdlePlan.md b/docs/reference/cmdlets/Export-IdlePlan.md new file mode 100644 index 00000000..a1d3958e --- /dev/null +++ b/docs/reference/cmdlets/Export-IdlePlan.md @@ -0,0 +1,131 @@ +--- +external help file: IdLE-help.xml +Module Name: IdLE +online version: +schema: 2.0.0 +--- + +# Export-IdlePlan + +## SYNOPSIS +Exports an IdLE LifecyclePlan as a canonical JSON artifact. + +## SYNTAX + +``` +Export-IdlePlan [-Plan] [[-Path] ] [-PassThru] [-ProgressAction ] + [] +``` + +## DESCRIPTION +This cmdlet is the **user-facing** wrapper exposed by the IdLE meta module. + +It delegates to IdLE.Core's \`Export-IdlePlanObject\`, which implements the canonical +plan export contract. + +By default, the cmdlet returns a pretty-printed JSON string. +If -Path is provided, +the JSON is written to disk as UTF-8 (no BOM). +Use -PassThru to also return the JSON +string when writing a file. + +## EXAMPLES + +### EXAMPLE 1 +``` +$plan = New-IdlePlan -Request $request -Workflow $workflow -StepRegistry $registry +$plan | Export-IdlePlan +``` + +Exports the plan and returns the JSON string. + +### EXAMPLE 2 +``` +New-IdlePlan -Request $request -Workflow $workflow -StepRegistry $registry | + Export-IdlePlan -Path ./artifacts/plan.json +``` + +Exports the plan and writes the JSON to a file. + +### EXAMPLE 3 +``` +New-IdlePlan -Request $request -Workflow $workflow -StepRegistry $registry | + Export-IdlePlan -Path ./artifacts/plan.json -PassThru +``` + +Writes the file and also returns the JSON string. + +## PARAMETERS + +### -Plan +The LifecyclePlan object to export. +Accepts pipeline input. + +```yaml +Type: Object +Parameter Sets: (All) +Aliases: + +Required: True +Position: 1 +Default value: None +Accept pipeline input: True (ByValue) +Accept wildcard characters: False +``` + +### -Path +Optional file path to write the JSON artifact to. + +```yaml +Type: String +Parameter Sets: (All) +Aliases: + +Required: False +Position: 2 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -PassThru +When -Path is used, returns the JSON string in addition to writing the file. + +```yaml +Type: SwitchParameter +Parameter Sets: (All) +Aliases: + +Required: False +Position: Named +Default value: False +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -ProgressAction +{{ Fill ProgressAction Description }} + +```yaml +Type: ActionPreference +Parameter Sets: (All) +Aliases: proga + +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### CommonParameters +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +## OUTPUTS + +### System.String +## NOTES + +## RELATED LINKS From fad017b4e7bee6af7dbeb5ababa6b078b3b38449 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:34:25 +0100 Subject: [PATCH 11/15] fix(core): retain request intent snapshot for plan export --- .../ConvertTo-IdlePlanExportObject.ps1 | 15 +++ src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 108 ++++++++++++++++-- tests/Export-IdlePlan.Tests.ps1 | 24 +++- .../plan-export/expected/plan-export.json | 10 +- 4 files changed, 144 insertions(+), 13 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index fe11b908..1ab45367 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -96,6 +96,21 @@ function ConvertTo-IdlePlanExportObject { # Keep input opaque. We do not transform or validate here. $requestInput = Get-FirstPropertyValue -Object $request -Names @('Input', 'Data', 'Payload', 'Attributes') + + if ($null -eq $requestInput) { + # IdLE lifecycle requests store business intent as IdentityKeys/DesiredState/Changes. + # When present, export these as the canonical request.input payload. + $identityKeys = Get-FirstPropertyValue -Object $request -Names @('IdentityKeys', 'IdentityKey', 'Keys') + $desiredState = Get-FirstPropertyValue -Object $request -Names @('DesiredState', 'TargetState') + $changes = Get-FirstPropertyValue -Object $request -Names @('Changes', 'Delta') + + if ($null -ne $identityKeys -or $null -ne $desiredState -or $null -ne $changes) { + $requestInput = New-OrderedMap + $requestInput.identityKeys = $identityKeys + $requestInput.desiredState = $desiredState + $requestInput.changes = $changes + } + } } else { # Plan-shaped fallback (current IdLE plan object shape). diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 1866428a..46fc91a4 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -14,7 +14,7 @@ function New-IdlePlanObject { Lifecycle request object (must contain LifecycleEvent and CorrelationId). .PARAMETER Providers - Optional provider registry/collection. Not used in this increment; stored for later. + Provider map passed through to the plan for later execution. .OUTPUTS PSCustomObject (PSTypeName: IdLE.Plan) @@ -34,6 +34,79 @@ function New-IdlePlanObject { [object] $Providers ) + function ConvertTo-NullIfEmptyString { + [CmdletBinding()] + param( + [Parameter()] + [object] $Value + ) + + if ($null -eq $Value) { + return $null + } + + if ($Value -is [string] -and [string]::IsNullOrWhiteSpace($Value)) { + return $null + } + + return $Value + } + + function Copy-IdleDataObject { + [CmdletBinding()] + param( + [Parameter()] + [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 + } + + # 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 + } + + # 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 $items + } + + # 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 + } + + # Fallback: return string representation (keeps export stable without leaking runtime handles). + return [string] $Value + } + # Ensure required request properties exist without hard-typing the request class. $reqProps = $Request.PSObject.Properties.Name if ($reqProps -notcontains 'LifecycleEvent') { @@ -43,6 +116,19 @@ function New-IdlePlanObject { throw [System.ArgumentException]::new("Request object must contain property 'CorrelationId'.", 'Request') } + # 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 } + } + # Validate workflow and ensure it matches the request's LifecycleEvent. $workflow = Test-IdleWorkflowDefinitionObject -WorkflowPath $WorkflowPath -Request $Request @@ -52,8 +138,9 @@ function New-IdlePlanObject { PSTypeName = 'IdLE.Plan' WorkflowName = [string]$workflow.Name LifecycleEvent = [string]$workflow.LifecycleEvent - CorrelationId = [string]$Request.CorrelationId - Actor = if ($reqProps -contains 'Actor') { [string]$Request.Actor } else { $null } + CorrelationId = [string]$requestSnapshot.CorrelationId + Request = $requestSnapshot + Actor = $requestSnapshot.Actor CreatedUtc = [DateTime]::UtcNow Steps = @() Actions = @() @@ -74,11 +161,15 @@ function New-IdlePlanObject { # Step conditions are evaluated during planning and may mark steps as NotApplicable. $normalizedSteps = @() foreach ($s in @($workflow.Steps)) { - - # Breaking change: "When" is no longer supported. Use "Condition" instead. + if (-not $s.ContainsKey('Name') -or [string]::IsNullOrWhiteSpace([string]$s.Name)) { + throw [System.ArgumentException]::new('Workflow step is missing required key "Name".', 'Workflow') + } + if (-not $s.ContainsKey('Type') -or [string]::IsNullOrWhiteSpace([string]$s.Type)) { + throw [System.ArgumentException]::new(("Workflow step '{0}' is missing required key 'Type'." -f [string]$s.Name), 'Workflow') + } if ($s.ContainsKey('When')) { throw [System.ArgumentException]::new( - "Workflow step '$($s.Name)' uses key 'When'. This has been renamed to 'Condition'. Please update the workflow definition.", + "Workflow step '$($s.Name)' uses key 'When'. 'When' has been renamed to 'Condition'. Please update the workflow definition.", 'Workflow' ) } @@ -105,13 +196,14 @@ function New-IdlePlanObject { PSTypeName = 'IdLE.PlanStep' Name = [string]$s.Name Type = [string]$s.Type - Description = if ($s.ContainsKey('Description')) { [string]$s.Description } else { $null } + Description = if ($s.ContainsKey('Description')) { [string]$s.Description } else { '' } Condition = $condition - With = if ($s.ContainsKey('With')) { $s.With } else { $null } # Parameter bag; validated later. + With = if ($s.ContainsKey('With')) { $s.With } else { @{} } Status = $status } } + # Attach steps to the plan after normalization. $plan.Steps = $normalizedSteps return $plan diff --git a/tests/Export-IdlePlan.Tests.ps1 b/tests/Export-IdlePlan.Tests.ps1 index 6f6e1948..13f59240 100644 --- a/tests/Export-IdlePlan.Tests.ps1 +++ b/tests/Export-IdlePlan.Tests.ps1 @@ -31,7 +31,13 @@ Describe 'Export-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -CorrelationId $cid + # IMPORTANT: Provide request intent payload so the export can include request.input. + $req = New-IdleLifecycleRequest ` + -LifecycleEvent 'Joiner' ` + -CorrelationId $cid ` + -IdentityKeys ([ordered]@{ userId = 'jdoe' }) ` + -DesiredState ([ordered]@{ department = 'IT' }) + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } $expectedPath = Join-Path $PSScriptRoot 'fixtures/plan-export/expected/plan-export.json' @@ -41,7 +47,9 @@ Describe 'Export-IdlePlan' { $actualJson = $plan | Export-IdlePlan # Assert - $actualJson.TrimEnd() | Should -Be $expectedJson.TrimEnd() + # Normalize trailing whitespace (EOF newline differences) and line endings (Windows/Linux). + ($actualJson -replace "`r`n", "`n").TrimEnd() | + Should -Be (($expectedJson -replace "`r`n", "`n").TrimEnd()) } } @@ -60,7 +68,11 @@ Describe 'Export-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -CorrelationId $cid + $req = New-IdleLifecycleRequest ` + -LifecycleEvent 'Joiner' ` + -CorrelationId $cid ` + -IdentityKeys ([ordered]@{ userId = 'jdoe' }) + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } $outFile = Join-Path $TestDrive 'plan.json' @@ -91,7 +103,11 @@ Describe 'Export-IdlePlan' { } '@ - $req = New-IdleLifecycleRequest -LifecycleEvent 'Joiner' -CorrelationId $cid + $req = New-IdleLifecycleRequest ` + -LifecycleEvent 'Joiner' ` + -CorrelationId $cid ` + -IdentityKeys ([ordered]@{ userId = 'jdoe' }) + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers @{ Dummy = $true } # Act diff --git a/tests/fixtures/plan-export/expected/plan-export.json b/tests/fixtures/plan-export/expected/plan-export.json index 1325fe2b..5f2e5876 100644 --- a/tests/fixtures/plan-export/expected/plan-export.json +++ b/tests/fixtures/plan-export/expected/plan-export.json @@ -7,7 +7,15 @@ "type": "Joiner", "correlationId": "11111111-1111-1111-1111-111111111111", "actor": null, - "input": null + "input": { + "identityKeys": { + "userId": "jdoe" + }, + "desiredState": { + "department": "IT" + }, + "changes": null + } }, "plan": { "id": "11111111-1111-1111-1111-111111111111", From 99b008deebd80c222ac963d043acd4ee578153ea Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:50:17 +0100 Subject: [PATCH 12/15] fix(tools): md linting issues newlines --- tools/Generate-IdleStepReference.ps1 | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tools/Generate-IdleStepReference.ps1 b/tools/Generate-IdleStepReference.ps1 index b2517291..b8d28443 100644 --- a/tools/Generate-IdleStepReference.ps1 +++ b/tools/Generate-IdleStepReference.ps1 @@ -205,7 +205,7 @@ function ConvertTo-IdleStepMarkdownSection { $sb = New-Object System.Text.StringBuilder - [void]$sb.AppendLine("### $stepType") + [void]$sb.AppendLine("## $stepType") [void]$sb.AppendLine() [void]$sb.AppendLine(("- **Step Name**: `$stepType")) [void]$sb.AppendLine(("- **Implementation**: `$commandName")) @@ -230,15 +230,13 @@ function ConvertTo-IdleStepMarkdownSection { if ($requiredWithKeys.Count -eq 0) { [void]$sb.AppendLine('_Unknown (not detected automatically). Document required With.* keys in the step help and/or use a supported pattern._') - [void]$sb.AppendLine() } else { [void]$sb.AppendLine('| Key | Required |') - [void]$sb.AppendLine('|---|---|') + [void]$sb.AppendLine('| --- | --- |') foreach ($k in $requiredWithKeys) { - [void]$sb.AppendLine("| `$k` | Yes |") + [void]$sb.AppendLine("| $k | Yes |") } - [void]$sb.AppendLine() } return $sb.ToString() @@ -338,7 +336,12 @@ foreach ($cmd in ($stepCommands | Sort-Object)) { } } -Set-Content -Path $OutputPath -Value $body.ToString() -Encoding utf8 -NoNewline +# Normalize output: +# - remove trailing whitespace/newlines introduced by StringBuilder +# - enforce exactly one LF at EOF (avoids "one newline too many" / dangling blank line issues) +$content = ($body.ToString().TrimEnd()) + "`n" + +Set-Content -Path $OutputPath -Value $content -Encoding utf8 -NoNewline $generatedFile = Get-Item -Path $OutputPath "Generated step reference: $($generatedFile.FullName) ($($generatedFile.Length) bytes)" From e78e4e059c97a7d637c6de9fb71db5a446358a64 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:50:24 +0100 Subject: [PATCH 13/15] updated steps docu --- docs/reference/steps.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/reference/steps.md b/docs/reference/steps.md index 677ba048..a75162cb 100644 --- a/docs/reference/steps.md +++ b/docs/reference/steps.md @@ -7,7 +7,7 @@ This page documents built-in IdLE steps discovered from `Invoke-IdleStep*` funct --- -### EmitEvent +## EmitEvent - **Step Name**: $stepType - **Implementation**: $commandName @@ -29,10 +29,9 @@ to write structured events. _Unknown (not detected automatically). Document required With.* keys in the step help and/or use a supported pattern._ - --- -### EnsureAttribute +## EnsureAttribute - **Step Name**: $stepType - **Implementation**: $commandName @@ -56,11 +55,9 @@ The step is idempotent by design: it converges state to the desired value. **Inputs (With.\*)** | Key | Required | -|---|---| -| $k | Yes | -| $k | Yes | -| $k | Yes | - +| --- | --- | +| IdentityKey | Yes | +| Name | Yes | +| Value | Yes | --- - From 20e33d444a37dd63ee64bf4838ffd7c84fdb431d Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:51:36 +0100 Subject: [PATCH 14/15] fix(core): documented intent snapshot --- docs/advanced/architecture.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index 2775d404..9cc3541a 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -27,6 +27,7 @@ Planning creates a deterministic plan: - evaluates declarative conditions - validates inputs and references - produces data-only actions +- captures a **data-only request intent snapshot** (e.g. IdentityKeys / DesiredState / Changes) for auditing and export ### Execute @@ -44,6 +45,14 @@ The canonical contract format is defined here: - [Plan export specification (JSON)](../specs/plan-export.md) +The exported artifact is intended for **approvals, CI checks, and audits**. +To keep exports deterministic and review-friendly, the contract intentionally omits volatile information +such as engine build versions and timestamps. When required, hosts SHOULD attach build/time metadata +outside the exported plan artifact. + +Because IdLE separates planning from execution, the plan retains a **request intent snapshot** so that +exports can include `request.input` even after the original request object is no longer available. + ## Declarative conditions Conditions are data-only objects. From ad406456af88c50590aa152cb3e712322d86e7d5 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Thu, 1 Jan 2026 15:04:56 +0100 Subject: [PATCH 15/15] docs: updated plan-export docu to request intent --- docs/specs/plan-export.md | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/docs/specs/plan-export.md b/docs/specs/plan-export.md index 97051d76..5124b6eb 100644 --- a/docs/specs/plan-export.md +++ b/docs/specs/plan-export.md @@ -75,24 +75,50 @@ Hosts that require engine build or release information SHOULD attach it as exter Represents the **business intent** that produced the plan. +The request object captures *why* a plan was created, independent of *how* it will be executed. + ```json "request": { "type": "Joiner", "correlationId": "123e4567-e89b-12d3-a456-426614174000", "actor": "HR-System", "input": { - "userId": "jdoe", - "department": "IT" + "identityKeys": { + "userId": "jdoe" + }, + "desiredState": { + "department": "IT" + }, + "changes": null } } ``` -Rules: +### Fields -- `input` is opaque to the engine -- No validation logic is implied by the export +| Field | Description | +| ------ | ------------- | +| type | Logical lifecycle request type (e.g. Joiner, Mover, Leaver) | +| correlationId | Stable identifier correlating request, plan, and execution | +| actor | Originator of the request (system or human), if available | +| input | Business intent payload (data-only) | ---- +### Rules + +- The `request` object represents **business intent**, not execution details. +- `input` is treated as **opaque by the engine**: + - the engine MUST NOT rely on input semantics + - no validation logic is implied by the export +- `input` MUST contain **data-only content**: + - no script blocks + - no executable expressions + - no runtime handles +- For **IdLE-native lifecycle requests**, `input` SHOULD contain: + - `identityKeys` – identifiers of the target identity + - `desiredState` – intended target state + - `changes` – explicit deltas, if applicable +- Hosts MAY include additional fields in `input`. +- The request payload is exported for **audit, approval, and traceability purposes** and MUST remain stable once the plan is created. ## Plan Object