From ed49fd88953ea28203ab2f09264c3ecc748c55c0 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:18:45 +0100 Subject: [PATCH 1/4] tests: validate built-in steps available in IdLE without global exports --- tests/ModuleSurface.Tests.ps1 | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/ModuleSurface.Tests.ps1 b/tests/ModuleSurface.Tests.ps1 index 10407208..790a992e 100644 --- a/tests/ModuleSurface.Tests.ps1 +++ b/tests/ModuleSurface.Tests.ps1 @@ -32,16 +32,31 @@ Describe 'Module manifests and public surface' { ) | Sort-Object $actual = (Get-Command -Module IdLE).Name | Sort-Object - $actual | Should -Be $expected } - It 'Importing IdLE does not load IdLE.Steps.Common by default' { - Remove-Module IdLE, IdLE.Steps.Common -Force -ErrorAction SilentlyContinue + It 'Importing IdLE makes built-in steps available to the engine without exporting them globally' { + Remove-Module IdLE, IdLE.Core, IdLE.Steps.Common -Force -ErrorAction SilentlyContinue Import-Module $idlePsd1 -Force -ErrorAction Stop - (Get-Module IdLE.Steps.Common) | Should -BeNullOrEmpty - (Get-Command Invoke-IdleStepEmitEvent -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty + # Built-in steps are expected to be available within IdLE (nested/hidden module is ok). + (Get-Module -All IdLE.Steps.Common) | Should -Not -BeNullOrEmpty + + # But they must not pollute the global session state: + (Get-Module -Name IdLE.Steps.Common) | Should -BeNullOrEmpty + (Get-Command -Name Invoke-IdleStepEmitEvent -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty + (Get-Command -Name Invoke-IdleStepEnsureAttribute -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty + + # Engine discovery must work without global exports (module-qualified handler names). + InModuleScope IdLE.Core { + $registry = Get-IdleStepRegistry -Providers $null + + $registry.ContainsKey('IdLE.Step.EmitEvent') | Should -BeTrue + $registry['IdLE.Step.EmitEvent'] | Should -Be 'IdLE.Steps.Common\Invoke-IdleStepEmitEvent' + + $registry.ContainsKey('IdLE.Step.EnsureAttribute') | Should -BeTrue + $registry['IdLE.Step.EnsureAttribute'] | Should -Be 'IdLE.Steps.Common\Invoke-IdleStepEnsureAttribute' + } } It 'Importing IdLE does not expose IdLE.Core object cmdlets globally' { @@ -52,7 +67,7 @@ Describe 'Module manifests and public surface' { (Get-Command Invoke-IdlePlanObject -ErrorAction SilentlyContinue) | Should -BeNullOrEmpty } - It 'IdLE module includes IdLE.Core as nested module' { + It 'IdLE module includes IdLE.Core and IdLE.Steps.Common as nested modules' { Remove-Module IdLE -Force -ErrorAction SilentlyContinue Import-Module $idlePsd1 -Force -ErrorAction Stop @@ -60,6 +75,7 @@ Describe 'Module manifests and public surface' { $idle | Should -Not -BeNullOrEmpty ($idle.NestedModules | Where-Object Name -eq 'IdLE.Core') | Should -Not -BeNullOrEmpty + ($idle.NestedModules | Where-Object Name -eq 'IdLE.Steps.Common') | Should -Not -BeNullOrEmpty } It 'Steps module exports the intended step functions' { From 6019df161a0d613e96e4bb7a158ec53df01b873f Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:18:59 +0100 Subject: [PATCH 2/4] feat: discover built-in steps via module exports without global import --- .../Private/Get-IdleStepRegistry.ps1 | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 index 83e4d850..81a693b0 100644 --- a/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 +++ b/src/IdLE.Core/Private/Get-IdleStepRegistry.ps1 @@ -18,6 +18,45 @@ function Get-IdleStepRegistry { $registry = [hashtable]::new([System.StringComparer]::OrdinalIgnoreCase) + # Helper: Resolve a step handler name without requiring global command exports. + # + # Resolution order: + # 1) Global command discovery (host imported a module globally) -> "Invoke-IdleStepX" + # 2) Module-scoped discovery (nested/hidden module loaded) -> "ModuleName\Invoke-IdleStepX" + function Resolve-IdleStepHandlerName { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $CommandName, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ModuleName + ) + + # 1) Global discovery (optional; supports hosts that import step packs globally) + $cmd = Get-Command -Name $CommandName -ErrorAction SilentlyContinue + if ($null -ne $cmd) { + return $cmd.Name + } + + # 2) Module-scoped discovery (supports nested modules that are not globally exported) + $module = Get-Module -Name $ModuleName -All | Select-Object -First 1 + if ($null -eq $module) { + return $null + } + + if ($null -ne $module.ExportedCommands -and $module.ExportedCommands.ContainsKey($CommandName)) { + + # Use a module-qualified command name so the engine can invoke it without relying on + # global session exports. This keeps built-in steps available "within IdLE" only. + return "$($module.Name)\$CommandName" + } + + return $null + } + # 1) Copy host-provided StepRegistry (optional) # We support two shapes for compatibility: # - Providers.StepRegistry (hashtable) @@ -65,18 +104,20 @@ function Get-IdleStepRegistry { # 2) Register built-in steps if available. # - # These are optional modules (Steps.Common, etc.). If they are not loaded, the registry entry is not added. + # Built-in steps are first-party step packs (e.g. IdLE.Steps.Common). They may be loaded as nested + # modules by the IdLE meta module. In that case, the step commands are not necessarily exported + # globally. We therefore support module-qualified handler names. if (-not $registry.ContainsKey('IdLE.Step.EmitEvent')) { - $cmd = Get-Command -Name 'Invoke-IdleStepEmitEvent' -ErrorAction SilentlyContinue - if ($null -ne $cmd) { - $registry['IdLE.Step.EmitEvent'] = $cmd.Name + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepEmitEvent' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.EmitEvent'] = $handler } } if (-not $registry.ContainsKey('IdLE.Step.EnsureAttribute')) { - $cmd = Get-Command -Name 'Invoke-IdleStepEnsureAttribute' -ErrorAction SilentlyContinue - if ($null -ne $cmd) { - $registry['IdLE.Step.EnsureAttribute'] = $cmd.Name + $handler = Resolve-IdleStepHandlerName -CommandName 'Invoke-IdleStepEnsureAttribute' -ModuleName 'IdLE.Steps.Common' + if (-not [string]::IsNullOrWhiteSpace($handler)) { + $registry['IdLE.Step.EnsureAttribute'] = $handler } } From 8b3325ea3ec51a840c17ee604244192705fff059 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:19:20 +0100 Subject: [PATCH 3/4] core: import built-in steps globally from IdLE meta module --- src/IdLE/IdLE.psd1 | 5 ++++- src/IdLE/IdLE.psm1 | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/IdLE/IdLE.psd1 b/src/IdLE/IdLE.psd1 index 1d7e2ff1..6fd2bb22 100644 --- a/src/IdLE/IdLE.psd1 +++ b/src/IdLE/IdLE.psd1 @@ -7,7 +7,10 @@ Description = 'IdentityLifecycleEngine (IdLE) meta-module. Imports IdLE.Core and optional packs.' PowerShellVersion = '7.0' - NestedModules = @('..\IdLE.Core\IdLE.Core.psd1') + NestedModules = @( + '..\IdLE.Core\IdLE.Core.psd1', + '..\IdLE.Steps.Common\IdLE.Steps.Common.psd1' + ) FunctionsToExport = @( 'Test-IdleWorkflow', diff --git a/src/IdLE/IdLE.psm1 b/src/IdLE/IdLE.psm1 index 3bbc9e43..ae87fe5a 100644 --- a/src/IdLE/IdLE.psm1 +++ b/src/IdLE/IdLE.psm1 @@ -30,13 +30,51 @@ function Import-IdleCoreModule { $coreManifestPath = Join-Path -Path $PSScriptRoot -ChildPath '..\IdLE.Core\IdLE.Core.psd1' if (-not (Test-Path -Path $coreManifestPath)) { - throw "Failed to load '$($script:IdleCoreModuleName)'. Module was not found via PSModulePath and local fallback path does not exist: $coreManifestPath" + throw "Failed to load '$($script:IdleCoreModuleName)'. Module not found in PSModulePath and local fallback path does not exist: $coreManifestPath" } Import-Module -Name $coreManifestPath -Force -ErrorAction Stop } + +# region Bootstrap - ensure built-in step packs are loaded +# The core engine is step-agnostic. This meta module provides a batteries-included +# experience by importing first-party step packs where available. + +$script:IdleBuiltInStepsModuleName = 'IdLE.Steps.Common' + +function Import-IdleBuiltInStepsModule { + [CmdletBinding()] + param() + + # Already loaded -> nothing to do + if (Get-Module -Name $script:IdleBuiltInStepsModuleName) { + return + } + + # 1) Try normal module resolution (e.g. installed from PSGallery) + try { + Import-Module -Name $script:IdleBuiltInStepsModuleName -ErrorAction Stop + return + } + catch { + # Continue with local fallback + } + + # 2) Fallback: repo clone layout (IdLE and packs side-by-side under /src) + $stepsManifestPath = Join-Path -Path $PSScriptRoot -ChildPath '..\IdLE.Steps.Common\IdLE.Steps.Common.psd1' + + if (-not (Test-Path -Path $stepsManifestPath)) { + Write-Verbose "Built-in steps module '$($script:IdleBuiltInStepsModuleName)' not found. Skipping import. Expected path: $stepsManifestPath" + return + } + + Import-Module -Name $stepsManifestPath -Force -ErrorAction Stop +} +# endregion + Import-IdleCoreModule +Import-IdleBuiltInStepsModule $PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' if (Test-Path -Path $PublicPath) { From 98089f1c86b705532c95cf3a5e9ae2c8db267df1 Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:34:10 +0100 Subject: [PATCH 4/4] docs: clarify built-in steps behavior in IdLE meta module --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7dc011ce..28d71f87 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,31 @@ cd IdentityLifecycleEngine Import-Module ./src/IdLE/IdLE.psd1 -Force ``` +#### What gets loaded when you import `IdLE` + +`IdLE` is the **batteries-included** entrypoint. Importing it loads: + +- `IdLE.Core` — the workflow engine (step-agnostic) +- `IdLE.Steps.Common` — first-party built-in steps (e.g. `IdLE.Step.EmitEvent`, `IdLE.Step.EnsureAttribute`) + +Built-in steps are **available to the engine by default**, but are intentionally **not exported into the global session state**. +This keeps your PowerShell session clean while still allowing workflows to reference built-in steps by `Step.Type`. + +If you want to call step functions directly (e.g. `Invoke-IdleStepEmitEvent`) you can explicitly import the step pack: + +```powershell +Import-Module ./src/IdLE.Steps.Common/IdLE.Steps.Common.psd1 -Force +``` + +#### Engine-only import + +Advanced hosts can import the engine without any step packs: + +```powershell +Import-Module ./src/IdLE.Core/IdLE.Core.psd1 -Force +``` + + ### Option B — PowerShell Gallery (planned) Once published: @@ -87,7 +112,7 @@ The demo shows: - creating a lifecycle request - building a deterministic plan from a workflow definition (`.psd1`) -- executing the plan using a host-provided step registry +- executing the plan using built-in steps (and optionally a host-provided step registry for extensions) The execution result buffers all emitted events in `result.Events`. Hosts can optionally stream events live by providing `-EventSink` as an object implementing `WriteEvent(event)`.