From ee3b7abc37a921f0ba55638232521b7c36d5aa2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 13:36:35 +0000 Subject: [PATCH 1/9] Initial plan From 1b79c6e60d735bdf4466500fb2c968725907d45a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 13:53:44 +0000 Subject: [PATCH 2/9] Introduce Request.Intent/Context; deprecate DesiredState; forbid Request.Identity Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/extend/extensibility.md | 2 +- docs/reference/cmdlets/New-IdleRequest.md | 64 ++++++++-- docs/reference/providers/provider-mock.md | 2 +- docs/use/quickstart.md | 8 +- .../use/walkthrough/01-workflow-definition.md | 6 +- docs/use/walkthrough/02-request-creation.md | 12 +- docs/use/walkthrough/03-plan-creation.md | 4 +- .../05-providers-authentication.md | 6 +- docs/use/workflows.md | 16 +-- examples/Invoke-LeaverWithManagerOOF.ps1 | 15 ++- .../ad-joiner-entraconnect-entraid.psd1 | 16 +-- examples/workflows/templates/ad-joiner.psd1 | 72 +++++------ examples/workflows/templates/ad-leaver.psd1 | 26 ++-- ...rectorysync-entraconnect-trigger-sync.psd1 | 4 +- .../templates/entraid-exo-leaver.psd1 | 12 +- .../workflows/templates/entraid-joiner.psd1 | 64 +++++----- .../workflows/templates/entraid-leaver.psd1 | 18 +-- examples/workflows/templates/exo-joiner.psd1 | 6 +- examples/workflows/templates/exo-leaver.psd1 | 14 +-- .../ConvertTo-IdlePlanExportObject.ps1 | 8 +- .../Private/IdleLifecycleRequest.ps1 | 26 +++- .../Private/Resolve-IdleTemplateString.ps1 | 29 +++-- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 10 ++ .../Public/New-IdleRequestObject.ps1 | 72 +++++++++-- src/IdLE/Public/New-IdleRequest.ps1 | 27 +++- tests/Core/New-IdleRequest.Tests.ps1 | 115 +++++++++++++++++- .../Resolve-IdleWorkflowTemplates.Tests.ps1 | 36 ++++++ tests/_testHelpers.ps1 | 12 +- .../plan-export/expected/plan-export.json | 2 +- .../template-tests/template-context.psd1 | 13 ++ .../template-tests/template-intent.psd1 | 13 ++ 31 files changed, 536 insertions(+), 194 deletions(-) create mode 100644 tests/fixtures/workflows/template-tests/template-context.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-intent.psd1 diff --git a/docs/extend/extensibility.md b/docs/extend/extensibility.md index 21da75eb..1a95cec6 100644 --- a/docs/extend/extensibility.md +++ b/docs/extend/extensibility.md @@ -141,4 +141,4 @@ The following are **not contracts** and may change in minor/patch versions: **Lifecycle request contract**: - Required fields: `LifecycleEvent`, `CorrelationId` -- Optional fields: `Actor`, `IdentityKeys`, `DesiredState`, `Changes` +- Optional fields: `Actor`, `IdentityKeys`, `Intent`, `Context`, `Changes` diff --git a/docs/reference/cmdlets/New-IdleRequest.md b/docs/reference/cmdlets/New-IdleRequest.md index f4aa9015..b860ac28 100644 --- a/docs/reference/cmdlets/New-IdleRequest.md +++ b/docs/reference/cmdlets/New-IdleRequest.md @@ -13,9 +13,10 @@ Creates a lifecycle request object. ## SYNTAX ``` -New-IdleRequest [-LifecycleEvent] <String> [[-CorrelationId] <String>] [[-Actor] <String>] - [[-IdentityKeys] <Hashtable>] [[-DesiredState] <Hashtable>] [[-Changes] <Hashtable>] - [-ProgressAction <ActionPreference>] [<CommonParameters>] +New-IdleRequest [-LifecycleEvent] [[-CorrelationId] ] [[-Actor] ] + [[-IdentityKeys] ] [[-Intent] ] [[-Context] ] + [[-DesiredState] ] [[-Changes] ] + [-ProgressAction ] [] ``` ## DESCRIPTION @@ -26,6 +27,11 @@ CorrelationId is generated if missing. Actor is optional. Changes is optional and stays $null when omitted. +Transition window (DesiredState → Intent): +- Providing only -DesiredState maps it to -Intent and emits a deprecation warning. +- Providing both -DesiredState and -Intent fails fast with a validation error. +- After the transition window, -DesiredState support will be removed. + ## EXAMPLES ### EXAMPLE 1 @@ -33,6 +39,11 @@ Changes is optional and stays $null when omitted. New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } ``` +### EXAMPLE 2 +``` +New-IdleRequest -LifecycleEvent Joiner -Intent @{ Department = 'Engineering'; Title = 'Engineer' } +``` + ## PARAMETERS ### -LifecycleEvent @@ -99,8 +110,10 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -DesiredState -A hashtable describing the desired state (attributes, entitlements, etc.). +### -Intent +A hashtable containing the caller-provided action inputs for the workflow (attributes, +entitlements, operator flags, etc.). +Canonical replacement for DesiredState. ```yaml Type: Hashtable @@ -109,7 +122,44 @@ Aliases: Required: False Position: 5 -Default value: @{} +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -Context +A hashtable containing read-only associated context provided by the host or resolvers +(e.g. +identity snapshots, device hints). +Must not be treated as mutable state within IdLE. + +```yaml +Type: Hashtable +Parameter Sets: (All) +Aliases: + +Required: False +Position: 6 +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + +### -DesiredState +Deprecated. +Use -Intent instead. +Providing only -DesiredState maps it to -Intent and emits +a deprecation warning. +Providing both -DesiredState and -Intent is an error. + +```yaml +Type: Hashtable +Parameter Sets: (All) +Aliases: + +Required: False +Position: 7 +Default value: None Accept pipeline input: False Accept wildcard characters: False ``` @@ -123,7 +173,7 @@ Parameter Sets: (All) Aliases: Required: False -Position: 6 +Position: 8 Default value: None Accept pipeline input: False Accept wildcard characters: False diff --git a/docs/reference/providers/provider-mock.md b/docs/reference/providers/provider-mock.md index 576e861c..149e5b01 100644 --- a/docs/reference/providers/provider-mock.md +++ b/docs/reference/providers/provider-mock.md @@ -16,7 +16,7 @@ import CodeBlock from '@theme/CodeBlock'; Use the Mock provider when you want to: - validate **workflow logic**, conditions, and error handling -- validate **template placeholders** (e.g. `{{Request.Input...}}`) without external dependencies +- validate **template placeholders** (e.g. `{{Request.Intent...}}`) without external dependencies - build demos or CI checks that should never modify production systems Non-goals: diff --git a/docs/use/quickstart.md b/docs/use/quickstart.md index 2db77173..df3cb642 100644 --- a/docs/use/quickstart.md +++ b/docs/use/quickstart.md @@ -77,8 +77,8 @@ $workflowContent = @' Provider = 'Identity' IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' Attributes = @{ - GivenName = '{{Request.DesiredState.GivenName}}' - Surname = '{{Request.DesiredState.Surname}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' } } } @@ -105,7 +105,7 @@ A request represents business intent (Joiner/Mover/Leaver) plus input data. ```powershell $request = New-IdleRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ EmployeeId = '12345' -} -DesiredState @{ +} -Intent @{ GivenName = 'Max' Surname = 'Power' } @@ -128,7 +128,7 @@ $providers = @{ ## 5) Build the plan (validation + template resolution) Plan building is a **fail-fast** step. IdLE validates the workflow and resolves templates like -`{{Request.DesiredState.GivenName}}`. +`{{Request.Intent.GivenName}}`. ```powershell $plan = New-IdlePlan -WorkflowPath $workflowPath -Request $request -Providers $providers diff --git a/docs/use/walkthrough/01-workflow-definition.md b/docs/use/walkthrough/01-workflow-definition.md index ac18c365..7660bd73 100644 --- a/docs/use/walkthrough/01-workflow-definition.md +++ b/docs/use/walkthrough/01-workflow-definition.md @@ -52,8 +52,8 @@ Create a file `joiner.psd1` with this content: Provider = 'Identity' IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' Attributes = @{ - GivenName = '{{Request.DesiredState.GivenName}}' - Surname = '{{Request.DesiredState.Surname}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' } } } @@ -74,7 +74,7 @@ Workflows are treated as **untrusted input** and must remain **data-only**. - `Steps` is an ordered list. - Each step references a **StepType** by name (`Type`). - Step configuration lives under `With`. -- Template expressions like `{{Request.DesiredState.GivenName}}` are resolved when building the plan. +- Template expressions like `{{Request.Intent.GivenName}}` are resolved when building the plan. --- diff --git a/docs/use/walkthrough/02-request-creation.md b/docs/use/walkthrough/02-request-creation.md index a73349a9..ada1de62 100644 --- a/docs/use/walkthrough/02-request-creation.md +++ b/docs/use/walkthrough/02-request-creation.md @@ -20,7 +20,7 @@ Create a minimal request that matches the workflow from [Walkthrough 1](01-workf - A request object that contains: - `LifecycleEvent` - `IdentityKeys.EmployeeId` - - `DesiredState.GivenName` and `DesiredState.Surname` + - `Intent.GivenName` and `Intent.Surname` --- @@ -31,7 +31,7 @@ In PowerShell, create the request like this: ```powershell $request = New-IdleRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ EmployeeId = '12345' -} -DesiredState @{ +} -Intent @{ GivenName = 'Max' Surname = 'Power' } @@ -40,8 +40,8 @@ $request = New-IdleRequest -LifecycleEvent 'Joiner' -IdentityKeys @{ This request provides the values referenced in the workflow templates: - `{{Request.IdentityKeys.EmployeeId}}` -- `{{Request.DesiredState.GivenName}}` -- `{{Request.DesiredState.Surname}}` +- `{{Request.Intent.GivenName}}` +- `{{Request.Intent.Surname}}` --- @@ -56,8 +56,8 @@ Identity keys are typically: - unique - provided by the upstream system (HR, IAM, ticket) -### DesiredState -Desired state contains the data you want IdLE to enforce (attributes, entitlements, mailbox settings, …). +### Intent +Intent contains the caller-provided action inputs (attributes, entitlements, mailbox settings, …) that the workflow should act on. For this walkthrough we keep it minimal and only set two attributes. diff --git a/docs/use/walkthrough/03-plan-creation.md b/docs/use/walkthrough/03-plan-creation.md index 8953dd15..622dba1c 100644 --- a/docs/use/walkthrough/03-plan-creation.md +++ b/docs/use/walkthrough/03-plan-creation.md @@ -19,7 +19,7 @@ Build a plan from your workflow and request, while supplying providers (recommen ## You will have - A plan object that is safe to review and execute -- Templates resolved (for example `{{Request.DesiredState.GivenName}}`) +- Templates resolved (for example `{{Request.Intent.GivenName}}`) - Validation errors surfaced early (before execution) --- @@ -59,7 +59,7 @@ During plan build IdLE typically: - validates the workflow structure and step types - validates that referenced providers exist (when supplied) - checks required capabilities (provider/step contracts) -- resolves template expressions (for example `{{Request.DesiredState.GivenName}}`) +- resolves template expressions (for example `{{Request.Intent.GivenName}}`) - produces a deterministic execution plan :::info diff --git a/docs/use/walkthrough/05-providers-authentication.md b/docs/use/walkthrough/05-providers-authentication.md index ba0d1420..7bf67020 100644 --- a/docs/use/walkthrough/05-providers-authentication.md +++ b/docs/use/walkthrough/05-providers-authentication.md @@ -51,8 +51,8 @@ Example workflow usage: Provider = 'Identity' IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' Attributes = @{ - GivenName = '{{Request.DesiredState.GivenName}}' - Surname = '{{Request.DesiredState.Surname}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' } } } @@ -108,7 +108,7 @@ Example (step requests a named session): IdentityKey = '{{Request.IdentityKeys.EmployeeId}}' Attributes = @{ - Department = '{{Request.DesiredState.Department}}' + Department = '{{Request.Intent.Department}}' } } } diff --git a/docs/use/workflows.md b/docs/use/workflows.md index c32f96c0..8dab8d81 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -74,8 +74,8 @@ request during plan build (`New-IdlePlan`). Multiple placeholders may appear in ```powershell IdentityKey = '{{Request.IdentityKeys.sAMAccountName}}' -DisplayName = '{{Request.DesiredState.GivenName}}' -Message = 'User {{Request.DesiredState.DisplayName}} is joining.' +DisplayName = '{{Request.Intent.GivenName}}' +Message = 'User {{Request.Intent.DisplayName}} is joining.' ``` ### Allowed roots @@ -84,13 +84,15 @@ For security, only these path roots are permitted: | Root | Description | | ---- | ----------- | -| `Request.DesiredState.*` | Intended target state of the identity | +| `Request.Intent.*` | Caller-provided action inputs (canonical) | +| `Request.Context.*` | Read-only associated context (host/resolver-provided) | | `Request.IdentityKeys.*` | Identifiers of the target identity | | `Request.Changes.*` | Explicit deltas (Mover events) | | `Request.LifecycleEvent` | Lifecycle event type (e.g. `Joiner`) | | `Request.CorrelationId` | Stable correlation identifier | | `Request.Actor` | Originator of the request | -| `Request.Input.*` | Alias for `Request.DesiredState.*` when no `Input` property exists | +| `Request.DesiredState.*` | Deprecated alias for `Request.Intent.*` (transition window) | +| `Request.Input.*` | Legacy alias for `Request.Intent.*` when no `Input` property exists | ### Pure vs. mixed placeholders @@ -98,13 +100,13 @@ A value containing **only** a single placeholder preserves the resolved type (bo ```powershell # Resolves to the actual [bool] value, not the string "True" -Enabled = '{{Request.DesiredState.IsEnabled}}' +Enabled = '{{Request.Intent.IsEnabled}}' ``` A value with surrounding text always produces a **string**: ```powershell -Message = 'Account for {{Request.DesiredState.DisplayName}} created.' +Message = 'Account for {{Request.Intent.DisplayName}} created.' ``` ### Backslash and special characters @@ -140,7 +142,7 @@ Summary of backslash behaviour: | `DOMAIN\{{Request.IdentityKeys.sAMAccountName}}` | `DOMAIN\jdoe` — `\` literal, valid template resolved | | `Literal \{{ braces here` | `Literal {{ braces here` — escape applied | | `\{{Request.InvalidRoot}}` | `{{Request.InvalidRoot}}` — invalid root, escape applied | -| `Literal \{{ and {{Request.Input.Name}}` | `Literal {{ and TestName` — escape + template | +| `Literal \{{ and {{Request.Intent.Name}}` | `Literal {{ and TestName` — escape + template | ### Validation diff --git a/examples/Invoke-LeaverWithManagerOOF.ps1 b/examples/Invoke-LeaverWithManagerOOF.ps1 index bf23be82..3dfc195a 100644 --- a/examples/Invoke-LeaverWithManagerOOF.ps1 +++ b/examples/Invoke-LeaverWithManagerOOF.ps1 @@ -10,7 +10,7 @@ template variables in Out of Office messages. Key concepts: - Manager lookup is performed HOST-SIDE, not inside workflow steps - Request enrichment happens before calling New-IdlePlan -- Templates like {{Request.DesiredState.Manager.DisplayName}} are resolved during planning +- Templates like {{Request.Intent.Manager.DisplayName}} are resolved during planning .NOTES This is an example only. Adapt authentication, provider setup, and directory queries @@ -112,18 +112,18 @@ switch ($DirectorySource) { } } -# 2. Build lifecycle request with enriched DesiredState +# 2. Build lifecycle request with caller-provided Intent Write-Host "==> Building lifecycle request..." -ForegroundColor Cyan -$desiredState = @{} +$intent = @{} if ($managerInfo) { - $desiredState['Manager'] = $managerInfo + $intent['Manager'] = $managerInfo } else { # Fallback: use generic support contact Write-Warning "No manager found; using generic support contact in OOF message." - $desiredState['Manager'] = @{ + $intent['Manager'] = @{ DisplayName = 'IT Support' Mail = 'support@contoso.com' } @@ -132,10 +132,9 @@ else { $request = New-IdleRequest ` -LifecycleEvent 'Leaver' ` -Actor $env:USERNAME ` - -Input @{ + -Intent (@{ UserPrincipalName = $UserPrincipalName - } ` - -DesiredState $desiredState + } + $intent) Write-Host " Request CorrelationId: $($request.CorrelationId)" -ForegroundColor Gray diff --git a/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 b/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 index c19cd4d0..39c55d80 100644 --- a/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 +++ b/examples/workflows/templates/ad-joiner-entraconnect-entraid.psd1 @@ -9,14 +9,14 @@ Type = 'IdLE.Step.CreateIdentity' With = @{ Provider = 'Directory' - AuthSessionName = '{{Request.Input.Auth.Directory}}' + AuthSessionName = '{{Request.Intent.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Attributes = @{ - GivenName = '{{Request.Input.GivenName}}' - Surname = '{{Request.Input.Surname}}' - Department = '{{Request.Input.Department}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' + Department = '{{Request.Intent.Department}}' } } } @@ -48,12 +48,12 @@ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Entitlement = @{ Kind = 'Group' - Id = '{{Request.Input.AllEmployeesGroupId}}' - DisplayName = '{{Request.Input.AllEmployeesGroupName}}' + Id = '{{Request.Intent.AllEmployeesGroupId}}' + DisplayName = '{{Request.Intent.AllEmployeesGroupName}}' } State = 'Present' } diff --git a/examples/workflows/templates/ad-joiner.psd1 b/examples/workflows/templates/ad-joiner.psd1 index 74f4dd66..5b5ba603 100644 --- a/examples/workflows/templates/ad-joiner.psd1 +++ b/examples/workflows/templates/ad-joiner.psd1 @@ -14,13 +14,13 @@ # Provider-specific: identify the target identity # The exact key names depend on provider contracts; keep it consistent with your provider docs. - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Optional: initial attributes that are commonly required Attributes = @{ - GivenName = '{{Request.Input.GivenName}}' - Surname = '{{Request.Input.Surname}}' - DisplayName = '{{Request.Input.DisplayName}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' + DisplayName = '{{Request.Intent.DisplayName}}' } } } @@ -30,16 +30,16 @@ Name = 'Ensure core attributes' With = @{ AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Attributes = @{ - Mail = '{{Request.Input.Mail}}' - Department = '{{Request.Input.Department}}' - Title = '{{Request.Input.Title}}' - Company = '{{Request.Input.Company}}' - Office = '{{Request.Input.Office}}' - Manager = '{{Request.Input.ManagerSamAccountName}}' - TelephoneNumber = '{{Request.Input.Phone}}' + Mail = '{{Request.Intent.Mail}}' + Department = '{{Request.Intent.Department}}' + Title = '{{Request.Intent.Title}}' + Company = '{{Request.Intent.Company}}' + Office = '{{Request.Intent.Office}}' + Manager = '{{Request.Intent.ManagerSamAccountName}}' + TelephoneNumber = '{{Request.Intent.Phone}}' } } } @@ -49,11 +49,11 @@ Name = 'Ensure baseline group membership (1)' With = @{ AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Entitlement = @{ Kind = 'Group'; - Id = '{{Request.Input.BaselineGroups.0}}'; - DisplayName = '{{Request.Input.BaselineGroups.0}}' + Id = '{{Request.Intent.BaselineGroups.0}}'; + DisplayName = '{{Request.Intent.BaselineGroups.0}}' } State = 'Present' } @@ -63,11 +63,11 @@ Name = 'Ensure baseline group membership (2)' With = @{ AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Entitlement = @{ Kind = 'Group'; - Id = '{{Request.Input.BaselineGroups.1}}'; - DisplayName = '{{Request.Input.BaselineGroups.1}}' + Id = '{{Request.Intent.BaselineGroups.1}}'; + DisplayName = '{{Request.Intent.BaselineGroups.1}}' } State = 'Present' } @@ -83,14 +83,14 @@ Name = 'Mover: update org attributes (optional)' With = @{ # Guard by convention: only run when request indicates mover - Condition = '{{Request.Input.IsMover}}' + Condition = '{{Request.Intent.IsMover}}' AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Attributes = @{ - Department = '{{Request.Input.NewDepartment}}' - Title = '{{Request.Input.NewTitle}}' - Office = '{{Request.Input.NewOffice}}' - Manager = '{{Request.Input.NewManagerSamAccountName}}' + Department = '{{Request.Intent.NewDepartment}}' + Title = '{{Request.Intent.NewTitle}}' + Office = '{{Request.Intent.NewOffice}}' + Manager = '{{Request.Intent.NewManagerSamAccountName}}' Description = 'Moved on {{Request.Execution.Timestamp}}' } } @@ -100,12 +100,12 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Mover: adjust group memberships (optional, baseline 1)' With = @{ - Condition = '{{Request.Input.IsMover}}' + Condition = '{{Request.Intent.IsMover}}' AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Optional: baseline + department-specific groups. - Entitlement = @{ Kind = 'Group'; Id = '{{Request.Input.BaselineGroups.0}}' } + Entitlement = @{ Kind = 'Group'; Id = '{{Request.Intent.BaselineGroups.0}}' } State = 'Present' } } @@ -113,12 +113,12 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Mover: adjust group memberships (optional, baseline 2)' With = @{ - Condition = '{{Request.Input.IsMover}}' + Condition = '{{Request.Intent.IsMover}}' AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Optional: baseline + department-specific groups. - Entitlement = @{ Kind = 'Group'; Id = '{{Request.Input.BaselineGroups.1}}' } + Entitlement = @{ Kind = 'Group'; Id = '{{Request.Intent.BaselineGroups.1}}' } State = 'Present' } } @@ -126,12 +126,12 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Mover: adjust group memberships (optional, department 1)' With = @{ - Condition = '{{Request.Input.IsMover}}' + Condition = '{{Request.Intent.IsMover}}' AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Optional: baseline + department-specific groups. - Entitlement = @{ Kind = 'Group'; Id = '{{Request.Input.DepartmentGroups.0}}' } + Entitlement = @{ Kind = 'Group'; Id = '{{Request.Intent.DepartmentGroups.0}}' } State = 'Present' } } @@ -139,12 +139,12 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Mover: adjust group memberships (optional, department 2)' With = @{ - Condition = '{{Request.Input.IsMover}}' + Condition = '{{Request.Intent.IsMover}}' AuthSessionName = '{{Request.Auth.Directory}}' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Optional: baseline + department-specific groups. - Entitlement = @{ Kind = 'Group'; Id = '{{Request.Input.DepartmentGroups.1}}' } + Entitlement = @{ Kind = 'Group'; Id = '{{Request.Intent.DepartmentGroups.1}}' } State = 'Present' } } diff --git a/examples/workflows/templates/ad-leaver.psd1 b/examples/workflows/templates/ad-leaver.psd1 index 7bf4e6da..3924d76d 100644 --- a/examples/workflows/templates/ad-leaver.psd1 +++ b/examples/workflows/templates/ad-leaver.psd1 @@ -9,8 +9,8 @@ Name = 'Disable identity' With = @{ AuthSessionName = 'Directory' - IdentityKey = '{{Request.Input.SamAccountName}}' - Reason = '{{Request.Input.LeaverReason}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' + Reason = '{{Request.Intent.LeaverReason}}' } } @@ -19,9 +19,9 @@ Name = 'Stamp offboarding attributes' With = @{ AuthSessionName = 'Directory' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' Attributes = @{ - Description = 'Leaver (CorrelationId: {{Request.CorrelationId}}) - {{Request.Input.LeaverReason}}' + Description = 'Leaver (CorrelationId: {{Request.CorrelationId}}) - {{Request.Intent.LeaverReason}}' } } } @@ -33,14 +33,14 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Remove managed group memberships (optional, item 1)' With = @{ - Condition = @{ Equals = @{ Path = 'Request.Input.RemoveGroups'; Value = $true } } + Condition = @{ Equals = @{ Path = 'Request.Intent.RemoveGroups'; Value = $true } } AuthSessionName = 'Directory' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Only remove what you explicitly manage via IdLE. Entitlement = @{ Kind = 'Group'; - Id = '{{Request.Input.ManagedGroupsToRemove.0}}' + Id = '{{Request.Intent.ManagedGroupsToRemove.0}}' } State = 'Absent' } @@ -49,14 +49,14 @@ Type = 'IdLE.Step.EnsureEntitlement' Name = 'Remove managed group memberships (optional, item 2)' With = @{ - Condition = @{ Equals = @{ Path = 'Request.Input.RemoveGroups'; Value = $true } } + Condition = @{ Equals = @{ Path = 'Request.Intent.RemoveGroups'; Value = $true } } AuthSessionName = 'Directory' - IdentityKey = '{{Request.Input.SamAccountName}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' # Only remove what you explicitly manage via IdLE. Entitlement = @{ Kind = 'Group'; - Id = '{{Request.Input.ManagedGroupsToRemove.1}}' + Id = '{{Request.Intent.ManagedGroupsToRemove.1}}' } State = 'Absent' } @@ -70,10 +70,10 @@ Type = 'IdLE.Step.MoveIdentity' Name = 'Move to Disabled OU (optional)' With = @{ - Condition = @{ Equals = @{ Path = 'Request.Input.MoveToDisabledOu'; Value = $true } } + Condition = @{ Equals = @{ Path = 'Request.Intent.MoveToDisabledOu'; Value = $true } } AuthSessionName = 'Directory' - IdentityKey = '{{Request.Input.SamAccountName}}' - TargetContainer = '{{Request.Input.DisabledOuPath}}' + IdentityKey = '{{Request.Intent.SamAccountName}}' + TargetContainer = '{{Request.Intent.DisabledOuPath}}' } } ) diff --git a/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 b/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 index 5f3af1cb..a8470938 100644 --- a/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 +++ b/examples/workflows/templates/directorysync-entraconnect-trigger-sync.psd1 @@ -17,7 +17,7 @@ } # Delta or Initial - PolicyType = '{{Request.Input.PolicyType}}' + PolicyType = '{{Request.Intent.PolicyType}}' # Optional wait/polling behavior (step-specific) Wait = $true @@ -30,7 +30,7 @@ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'Entra Connect sync cycle ({{Request.Input.PolicyType}}) triggered successfully.' + Message = 'Entra Connect sync cycle ({{Request.Intent.PolicyType}}) triggered successfully.' } } ) diff --git a/examples/workflows/templates/entraid-exo-leaver.psd1 b/examples/workflows/templates/entraid-exo-leaver.psd1 index 23cee81e..c70221de 100644 --- a/examples/workflows/templates/entraid-exo-leaver.psd1 +++ b/examples/workflows/templates/entraid-exo-leaver.psd1 @@ -8,7 +8,7 @@ Type = 'IdLE.Step.Mailbox.GetInfo' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } } } @{ @@ -16,7 +16,7 @@ Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } MailboxType = 'Shared' } } @@ -25,7 +25,7 @@ Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } Config = @{ Mode = 'Enabled' InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' @@ -41,7 +41,7 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserObjectId' } Desired = @() } } @@ -52,7 +52,7 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserObjectId' } Attributes = @{ Manager = $null } @@ -65,7 +65,7 @@ Provider = 'Identity' AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = @{ ValueFrom = 'Request.Input.UserObjectId' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserObjectId' } } } @{ diff --git a/examples/workflows/templates/entraid-joiner.psd1 b/examples/workflows/templates/entraid-joiner.psd1 index f0a54174..3755ce94 100644 --- a/examples/workflows/templates/entraid-joiner.psd1 +++ b/examples/workflows/templates/entraid-joiner.psd1 @@ -12,26 +12,26 @@ AuthSessionOptions = @{ Role = 'Admin' } # Using UPN keeps it human-friendly in templates. - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Attributes = @{ - UserPrincipalName = '{{Request.Input.UserPrincipalName}}' - DisplayName = '{{Request.Input.DisplayName}}' - GivenName = '{{Request.Input.GivenName}}' - Surname = '{{Request.Input.Surname}}' - Mail = '{{Request.Input.Mail}}' + UserPrincipalName = '{{Request.Intent.UserPrincipalName}}' + DisplayName = '{{Request.Intent.DisplayName}}' + GivenName = '{{Request.Intent.GivenName}}' + Surname = '{{Request.Intent.Surname}}' + Mail = '{{Request.Intent.Mail}}' # Optional org attributes (safe when empty) - Department = '{{Request.Input.Department}}' - JobTitle = '{{Request.Input.JobTitle}}' - OfficeLocation = '{{Request.Input.OfficeLocation}}' - CompanyName = '{{Request.Input.CompanyName}}' + Department = '{{Request.Intent.Department}}' + JobTitle = '{{Request.Intent.JobTitle}}' + OfficeLocation = '{{Request.Intent.OfficeLocation}}' + CompanyName = '{{Request.Intent.CompanyName}}' # Password profile is typically relevant for "new user" scenarios. - # Your host can generate and provide a temporary password in Request.Input. + # Your host can generate and provide a temporary password in Request.Intent. PasswordProfile = @{ forceChangePasswordNextSignIn = $true - password = '{{Request.Input.TemporaryPassword}}' + password = '{{Request.Intent.TemporaryPassword}}' } } } @@ -45,19 +45,19 @@ AuthSessionOptions = @{ Role = 'Admin' } # Using UPN keeps it human-friendly in templates. - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' # Baseline groups should be explicit and driven by request input (no hardcoding). Desired = @( @{ Kind = 'Group' - Id = '{{Request.Input.AllEmployeesGroupId}}' - DisplayName = '{{Request.Input.AllEmployeesGroupName}}' + Id = '{{Request.Intent.AllEmployeesGroupId}}' + DisplayName = '{{Request.Intent.AllEmployeesGroupName}}' } @{ Kind = 'Group' - Id = '{{Request.Input.DepartmentGroupId}}' - DisplayName = '{{Request.Input.DepartmentGroupName}}' + Id = '{{Request.Intent.DepartmentGroupId}}' + DisplayName = '{{Request.Intent.DepartmentGroupName}}' } ) } @@ -69,13 +69,13 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } # ---------------------------- # Mover patterns (optional) - # Enable by setting: Request.Input.IsMover = $true + # Enable by setting: Request.Intent.IsMover = $true # ---------------------------- @{ @@ -86,7 +86,7 @@ All = @( @{ Equals = @{ - Path = 'Request.Input.IsMover' + Path = 'Request.Intent.IsMover' Value = $true } } @@ -95,13 +95,13 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Attributes = @{ - Department = '{{Request.Input.NewDepartment}}' - JobTitle = '{{Request.Input.NewJobTitle}}' - OfficeLocation = '{{Request.Input.NewOfficeLocation}}' - Manager = '{{Request.Input.NewManagerObjectId}}' + Department = '{{Request.Intent.NewDepartment}}' + JobTitle = '{{Request.Intent.NewJobTitle}}' + OfficeLocation = '{{Request.Intent.NewOfficeLocation}}' + Manager = '{{Request.Intent.NewManagerObjectId}}' } } } @@ -113,7 +113,7 @@ All = @( @{ Equals = @{ - Path = 'Request.Input.IsMover' + Path = 'Request.Intent.IsMover' Value = $true } } @@ -122,19 +122,19 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' # Optional: add department/project groups as part of a move. Desired = @( @{ Kind = 'Group' - Id = '{{Request.Input.DepartmentGroupId}}' - DisplayName = '{{Request.Input.DepartmentGroupName}}' + Id = '{{Request.Intent.DepartmentGroupId}}' + DisplayName = '{{Request.Intent.DepartmentGroupName}}' } @{ Kind = 'Group' - Id = '{{Request.Input.ProjectGroupId}}' - DisplayName = '{{Request.Input.ProjectGroupName}}' + Id = '{{Request.Intent.ProjectGroupId}}' + DisplayName = '{{Request.Intent.ProjectGroupName}}' } ) } @@ -144,7 +144,7 @@ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'EntraID user {{Request.Input.UserPrincipalName}} created/updated successfully.' + Message = 'EntraID user {{Request.Intent.UserPrincipalName}} created/updated successfully.' } } ) diff --git a/examples/workflows/templates/entraid-leaver.psd1 b/examples/workflows/templates/entraid-leaver.psd1 index 926929bd..4c8cfdd8 100644 --- a/examples/workflows/templates/entraid-leaver.psd1 +++ b/examples/workflows/templates/entraid-leaver.psd1 @@ -12,7 +12,7 @@ AuthSessionOptions = @{ Role = 'Admin' } # Prefer ObjectId for leaver (stable), but you may also use UPN if your provider supports it. - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } @@ -22,7 +22,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } @@ -32,9 +32,9 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Attributes = @{ - DisplayName = '{{Request.Input.DisplayName}} (LEAVER)' + DisplayName = '{{Request.Intent.DisplayName}} (LEAVER)' Manager = $null } } @@ -49,7 +49,7 @@ All = @( @{ Equals = @{ - Path = 'Request.Input.RevokeAllGroupMemberships' + Path = 'Request.Intent.RevokeAllGroupMemberships' Value = $true } } @@ -58,7 +58,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Admin' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Entitlement = @{ Kind = 'Group'; Id = '*' @@ -75,7 +75,7 @@ All = @( @{ Equals = @{ - Path = 'Request.Input.DeleteAfterDisable' + Path = 'Request.Intent.DeleteAfterDisable' Value = $true } } @@ -84,7 +84,7 @@ With = @{ AuthSessionName = 'MicrosoftGraph' AuthSessionOptions = @{ Role = 'Tier0' } - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } @@ -92,7 +92,7 @@ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'EntraID user {{Request.Input.UserPrincipalName}} offboarding completed.' + Message = 'EntraID user {{Request.Intent.UserPrincipalName}} offboarding completed.' } } ) diff --git a/examples/workflows/templates/exo-joiner.psd1 b/examples/workflows/templates/exo-joiner.psd1 index 80006e73..6badfa0d 100644 --- a/examples/workflows/templates/exo-joiner.psd1 +++ b/examples/workflows/templates/exo-joiner.psd1 @@ -10,7 +10,7 @@ Description = 'Reads mailbox details (useful for auditing and troubleshooting).' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } @@ -20,7 +20,7 @@ Description = 'Ensures the mailbox is a regular user mailbox.' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' # Allowed values: User | Shared | Room | Equipment MailboxType = 'User' } @@ -32,7 +32,7 @@ Description = 'Ensures Out of Office is disabled for a new joiner mailbox.' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Config = @{ # Allowed values: Disabled | Enabled | Scheduled Mode = 'Disabled' diff --git a/examples/workflows/templates/exo-leaver.psd1 b/examples/workflows/templates/exo-leaver.psd1 index 0dc55402..3e23de51 100644 --- a/examples/workflows/templates/exo-leaver.psd1 +++ b/examples/workflows/templates/exo-leaver.psd1 @@ -9,7 +9,7 @@ Type = 'IdLE.Step.Mailbox.GetInfo' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' } } @{ @@ -17,7 +17,7 @@ Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' MailboxType = 'Shared' } } @@ -26,7 +26,7 @@ Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' - IdentityKey = '{{Request.Input.UserPrincipalName}}' + IdentityKey = '{{Request.Intent.UserPrincipalName}}' Config = @{ Mode = 'Enabled' MessageFormat = 'Html' @@ -35,14 +35,14 @@

This mailbox is no longer monitored.

For urgent matters, please contact:

'@ ExternalMessage = @'

This mailbox is no longer monitored.

-

Please contact our Service Desk at {{Request.Input.ServiceDesk.Mail}}.

+

Please contact our Service Desk at {{Request.Intent.ServiceDesk.Mail}}.

'@ ExternalAudience = 'All' @@ -53,7 +53,7 @@ Name = 'EmitCompletionEvent' Type = 'IdLE.Step.EmitEvent' With = @{ - Message = 'Mailbox offboarding completed for {{Request.Input.UserPrincipalName}}.' + Message = 'Mailbox offboarding completed for {{Request.Intent.UserPrincipalName}}.' } } ) diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index 1876070f..a1d8c083 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -80,16 +80,16 @@ function ConvertTo-IdlePlanExportObject { $requestInput = Get-FirstPropertyValue -Object $request -Names @('Input', 'Data', 'Payload', 'Attributes') if ($null -eq $requestInput) { - # IdLE lifecycle requests store business intent as IdentityKeys/DesiredState/Changes. + # IdLE lifecycle requests store business intent as IdentityKeys/Intent/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') + $intent = Get-FirstPropertyValue -Object $request -Names @('Intent', 'DesiredState', 'TargetState') $changes = Get-FirstPropertyValue -Object $request -Names @('Changes', 'Delta') - if ($null -ne $identityKeys -or $null -ne $desiredState -or $null -ne $changes) { + if ($null -ne $identityKeys -or $null -ne $intent -or $null -ne $changes) { $requestInput = New-OrderedMap $requestInput.identityKeys = $identityKeys - $requestInput.desiredState = $desiredState + $requestInput.intent = $intent $requestInput.changes = $changes } } diff --git a/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 b/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 index 0bdb65cc..9b2ac9b8 100644 --- a/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 +++ b/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 @@ -1,10 +1,16 @@ # Domain model: LifecycleRequest # Actor is intentionally optional in V1 (see architecture). -# Changes is optional and stays $null if not provided (intent-only requests typically only provide DesiredState). +# Changes is optional and stays $null if not provided. +# +# Intent - canonical caller-provided input block (replaces DesiredState). +# Context - read-only associated context provided by the host or resolvers. +# DesiredState - backward-compat alias mirroring Intent during the transition window. class IdleLifecycleRequest { [string] $LifecycleEvent [hashtable] $IdentityKeys + [hashtable] $Intent + [hashtable] $Context [hashtable] $DesiredState [hashtable] $Changes [string] $CorrelationId @@ -13,14 +19,16 @@ class IdleLifecycleRequest { IdleLifecycleRequest( [string] $lifecycleEvent, [hashtable] $identityKeys, - [hashtable] $desiredState, + [hashtable] $intent, + [hashtable] $context, [hashtable] $changes, [string] $correlationId, [string] $actor ) { $this.LifecycleEvent = $lifecycleEvent $this.IdentityKeys = $identityKeys - $this.DesiredState = $desiredState + $this.Intent = $intent + $this.Context = $context $this.Changes = $changes $this.CorrelationId = $correlationId $this.Actor = $actor @@ -37,10 +45,18 @@ class IdleLifecycleRequest { $this.IdentityKeys = @{} } - if ($null -eq $this.DesiredState) { - $this.DesiredState = @{} + if ($null -eq $this.Intent) { + $this.Intent = @{} } + if ($null -eq $this.Context) { + $this.Context = @{} + } + + # DesiredState mirrors Intent for backward compatibility during the transition window. + # Templates that reference Request.DesiredState.* continue to resolve correctly. + $this.DesiredState = $this.Intent + # Changes stays $null if not provided. If provided, it must be a hashtable. if ($null -ne $this.Changes -and $this.Changes -isnot [hashtable]) { throw [System.ArgumentException]::new('Changes must be a hashtable when provided.', 'Changes') diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index 92ed6540..35167c06 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -13,8 +13,10 @@ function Resolve-IdleTemplateString { - Multiple placeholders are supported in one string Allowed roots (security boundary): - - Request.Input.* (aliased to Request.DesiredState.* if Input does not exist) - - Request.DesiredState.* + - Request.Intent.* (canonical caller-provided action inputs) + - Request.Context.* (read-only associated context) + - Request.Input.* (aliased to Request.Intent.* if Input does not exist) + - Request.DesiredState.* (deprecated alias for Request.Intent.*; supported during transition window) - Request.IdentityKeys.* - Request.Changes.* - Request.LifecycleEvent @@ -72,7 +74,7 @@ function Resolve-IdleTemplateString { # Define validation constants used in multiple paths $pathValidationPattern = '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$' - $allowedRoots = @('Request.Input', 'Request.DesiredState', 'Request.IdentityKeys', 'Request.Changes', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor') + $allowedRoots = @('Request.Intent', 'Request.Context', 'Request.Input', 'Request.DesiredState', 'Request.IdentityKeys', 'Request.Changes', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor') # Helper function to validate path pattern $validatePath = { @@ -107,7 +109,7 @@ function Resolve-IdleTemplateString { $resolvePath = { param([string]$Path) - # Handle Request.Input.* alias to Request.DesiredState.* + # Handle Request.Input.* alias to Request.Intent.* (or Request.DesiredState.* for backward compat) $targetPath = $Path $hasInputProperty = $false if ($Request.PSObject.Properties['Input']) { @@ -116,13 +118,24 @@ function Resolve-IdleTemplateString { if ($Path.StartsWith('Request.Input.')) { if (-not $hasInputProperty) { - # Alias to DesiredState - $targetPath = $Path -replace '^Request\.Input\.', 'Request.DesiredState.' + # Alias to Intent (canonical); DesiredState mirrors Intent so either works + $targetPath = $Path -replace '^Request\.Input\.', 'Request.Intent.' } } elseif ($Path -eq 'Request.Input') { if (-not $hasInputProperty) { - $targetPath = 'Request.DesiredState' + $targetPath = 'Request.Intent' + } + } + + # Handle Request.DesiredState.* alias to Request.Intent.* when DesiredState is absent + # but Intent is present. This supports requests built with custom PSObjects that only + # expose Intent (no DesiredState backward-compat mirror). + if ($targetPath.StartsWith('Request.DesiredState.') -or $targetPath -eq 'Request.DesiredState') { + $hasDesiredStateProperty = $null -ne $Request.PSObject.Properties['DesiredState'] + $hasIntentProperty = $null -ne $Request.PSObject.Properties['Intent'] + if (-not $hasDesiredStateProperty -and $hasIntentProperty) { + $targetPath = $targetPath -replace '^Request\.DesiredState', 'Request.Intent' } } @@ -207,7 +220,7 @@ function Resolve-IdleTemplateString { # like \{{Request.Foo}} (invalid root) or \{{Request..Name}} (double dot) are still escaped to # literal {{, rather than flowing into template parsing and failing with path/root errors. $litOpenPlaceholder = [string][char]0xE001 - $backslashEscapePattern = '\\{{(?!Request\.(?:(?:Input|DesiredState|IdentityKeys|Changes)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)}})' + $backslashEscapePattern = '\\{{(?!Request\.(?:(?:Intent|Context|Input|DesiredState|IdentityKeys|Changes)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)}})' $normalizedValue = if ($stringValue -match '\\{{') { [regex]::Replace($stringValue, $backslashEscapePattern, $litOpenPlaceholder) } else { diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 12268cfb..7fd63739 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -54,6 +54,14 @@ function New-IdlePlanObject { throw [System.ArgumentException]::new("Request object must contain property 'CorrelationId'.", 'Request') } + # Reject requests that contain Request.Identity — identity snapshots belong under Request.Context.Identity. + if ($reqProps -contains 'Identity') { + throw [System.ArgumentException]::new( + "Request object must not contain property 'Identity'. Identity-related data belongs under 'Context.Identity' (see Request.Context).", + 'Request' + ) + } + # Create a data-only snapshot of the incoming request for deterministic exports. $requestSnapshot = [pscustomobject]@{ PSTypeName = 'IdLE.LifecycleRequestSnapshot' @@ -61,6 +69,8 @@ function New-IdlePlanObject { 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 } + Intent = if ($reqProps -contains 'Intent') { Copy-IdleDataObject -Value $Request.Intent } else { $null } + Context = if ($reqProps -contains 'Context') { Copy-IdleDataObject -Value $Request.Context } 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 } } diff --git a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 index cc28c12a..3724b9d1 100644 --- a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 +++ b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 @@ -8,8 +8,13 @@ function New-IdleRequestObject { (e.g. Joiner/Mover/Leaver). This is the core factory function used by the IdLE module wrapper. The function validates that no ScriptBlocks are present in the input data (IdentityKeys, - DesiredState, Changes) to enforce the data-only configuration principle. Input hashtables + Intent, Context, Changes) to enforce the data-only configuration principle. Input hashtables are cloned to prevent external mutation after object creation. + + Transition window (DesiredState → Intent): + - Providing only -DesiredState maps it to -Intent and emits a deprecation warning. + - Providing both -DesiredState and -Intent fails fast with a validation error. + - After the transition window, -DesiredState support will be removed. CorrelationId is preserved if provided; otherwise, the IdleLifecycleRequest class generates a new GUID. Actor is optional and not required by the core engine. @@ -30,9 +35,20 @@ function New-IdleRequestObject { A hashtable of system-neutral identity keys (e.g. @{ EmployeeId = '12345'; UPN = 'user@contoso.com' }). Defaults to an empty hashtable if not provided. Must not contain ScriptBlocks. + .PARAMETER Intent + A hashtable containing the caller-provided action inputs for the workflow (attributes, + entitlements, operator flags, etc.). Defaults to an empty hashtable if not provided. + Must not contain ScriptBlocks. Canonical replacement for DesiredState. + + .PARAMETER Context + A hashtable containing read-only associated context provided by the host or resolvers + (e.g. identity snapshots, device hints). Defaults to an empty hashtable if not provided. + Must not contain ScriptBlocks. Must not be treated as mutable state within IdLE. + .PARAMETER DesiredState - A hashtable describing the desired state for the identity (attributes, entitlements, etc.). - Defaults to an empty hashtable if not provided. Must not contain ScriptBlocks. + Deprecated. Use -Intent instead. A hashtable describing the desired state for the identity. + During the transition window, if only -DesiredState is provided it is mapped to -Intent + and a deprecation warning is emitted. Providing both -DesiredState and -Intent is an error. .PARAMETER Changes Optional hashtable describing changes (typically used for Mover lifecycle events to indicate @@ -41,12 +57,12 @@ function New-IdleRequestObject { .EXAMPLE $request = New-IdleRequestObject -LifecycleEvent 'Joiner' - Creates a minimal Joiner request with auto-generated CorrelationId and empty IdentityKeys/DesiredState. + Creates a minimal Joiner request with auto-generated CorrelationId and empty Intent/Context. .EXAMPLE - $request = New-IdleRequestObject -LifecycleEvent 'Joiner' -CorrelationId (New-Guid).Guid -IdentityKeys @{ EmployeeId = '12345' } -DesiredState @{ Department = 'Engineering'; MailNickname = 'jdoe'; Title = 'Engineer' } + $request = New-IdleRequestObject -LifecycleEvent 'Joiner' -CorrelationId (New-Guid).Guid -IdentityKeys @{ EmployeeId = '12345' } -Intent @{ Department = 'Engineering'; MailNickname = 'jdoe'; Title = 'Engineer' } - Creates a Joiner request with specific identity keys and desired state attributes for a typical onboarding workflow. + Creates a Joiner request with specific identity keys and intent attributes for a typical onboarding workflow. .EXAMPLE $request = New-IdleRequestObject -LifecycleEvent 'Mover' -IdentityKeys @{ UPN = 'user@contoso.com' } -Changes @{ Department = 'Sales' } -Actor 'admin@contoso.com' @@ -60,7 +76,7 @@ function New-IdleRequestObject { Security Considerations: - Input data must be data-only (no ScriptBlocks or executable objects). The function validates this constraint and throws if violated. - - Do not embed secrets in IdentityKeys, DesiredState, or Changes. Use the AuthSessionBroker + - Do not embed secrets in IdentityKeys, Intent, Context, or Changes. Use the AuthSessionBroker pattern for credential/token management. - Sensitive data in request objects may be logged or emitted in events. Rely on redaction boundaries defined in the engine's event sink and logging layers. @@ -84,28 +100,60 @@ function New-IdleRequestObject { [hashtable] $IdentityKeys = @{}, [Parameter()] - [hashtable] $DesiredState = @{}, + [hashtable] $Intent, + + [Parameter()] + [hashtable] $Context, + + [Parameter()] + [hashtable] $DesiredState, [Parameter()] [hashtable] $Changes ) + # Transition window: DesiredState → Intent migration. + $intentProvided = $PSBoundParameters.ContainsKey('Intent') + $desiredStateProvided = $PSBoundParameters.ContainsKey('DesiredState') + + if ($intentProvided -and $desiredStateProvided) { + throw [System.ArgumentException]::new( + "Both 'Intent' and 'DesiredState' were provided. 'DesiredState' is deprecated. Provide only 'Intent'.", + 'DesiredState' + ) + } + + if ($desiredStateProvided -and -not $intentProvided) { + # Map DesiredState → Intent and emit a structured deprecation warning. + Write-Warning ("IdLE deprecation: The 'DesiredState' parameter is deprecated and will be removed in a future release. " + + "Please migrate to '-Intent' (e.g. 'New-IdleRequest -LifecycleEvent ... -Intent @{...}'). " + + "For this request, 'DesiredState' has been mapped to 'Intent' automatically.") + $Intent = $DesiredState + } + + # Default to empty hashtables when neither was provided. + if ($null -eq $Intent) { $Intent = @{} } + if ($null -eq $Context) { $Context = @{} } + # Validate that no ScriptBlocks are present in the input data Assert-IdleNoScriptBlock -InputObject $IdentityKeys -Path 'IdentityKeys' - Assert-IdleNoScriptBlock -InputObject $DesiredState -Path 'DesiredState' + Assert-IdleNoScriptBlock -InputObject $Intent -Path 'Intent' + Assert-IdleNoScriptBlock -InputObject $Context -Path 'Context' Assert-IdleNoScriptBlock -InputObject $Changes -Path 'Changes' # Clone hashtables to avoid external mutation after object creation # shallow clone is sufficient as we have already validated no ScriptBlocks are present $IdentityKeys = if ($null -eq $IdentityKeys) { @{} } else { $IdentityKeys.Clone() } - $DesiredState = if ($null -eq $DesiredState) { @{} } else { $DesiredState.Clone() } - $Changes = if ($null -eq $Changes) { $null } else { $Changes.Clone() } + $Intent = if ($null -eq $Intent) { @{} } else { $Intent.Clone() } + $Context = if ($null -eq $Context) { @{} } else { $Context.Clone() } + $Changes = if ($null -eq $Changes) { $null } else { $Changes.Clone() } # Construct and return the core domain object defined in Private/IdleLifecycleRequest.ps1 return [IdleLifecycleRequest]::new( $LifecycleEvent, $IdentityKeys, - $DesiredState, + $Intent, + $Context, $Changes, $CorrelationId, $Actor diff --git a/src/IdLE/Public/New-IdleRequest.ps1 b/src/IdLE/Public/New-IdleRequest.ps1 index 14df2119..62881bd7 100644 --- a/src/IdLE/Public/New-IdleRequest.ps1 +++ b/src/IdLE/Public/New-IdleRequest.ps1 @@ -8,6 +8,11 @@ function New-IdleRequest { (e.g. Joiner/Mover/Leaver). CorrelationId is generated if missing. Actor is optional. Changes is optional and stays $null when omitted. + Transition window (DesiredState → Intent): + - Providing only -DesiredState maps it to -Intent and emits a deprecation warning. + - Providing both -DesiredState and -Intent fails fast with a validation error. + - After the transition window, -DesiredState support will be removed. + .PARAMETER LifecycleEvent The lifecycle event name (e.g. Joiner, Mover, Leaver). @@ -20,8 +25,17 @@ function New-IdleRequest { .PARAMETER IdentityKeys A hashtable of system-neutral identity keys (e.g. EmployeeId, UPN, ObjectId). + .PARAMETER Intent + A hashtable containing the caller-provided action inputs for the workflow (attributes, + entitlements, operator flags, etc.). Canonical replacement for DesiredState. + + .PARAMETER Context + A hashtable containing read-only associated context provided by the host or resolvers + (e.g. identity snapshots, device hints). Must not be treated as mutable state within IdLE. + .PARAMETER DesiredState - A hashtable describing the desired state (attributes, entitlements, etc.). + Deprecated. Use -Intent instead. Providing only -DesiredState maps it to -Intent and emits + a deprecation warning. Providing both -DesiredState and -Intent is an error. .PARAMETER Changes Optional hashtable describing changes (typically used for Mover lifecycle events). @@ -29,6 +43,9 @@ function New-IdleRequest { .EXAMPLE New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } + .EXAMPLE + New-IdleRequest -LifecycleEvent Joiner -Intent @{ Department = 'Engineering'; Title = 'Engineer' } + .OUTPUTS IdleLifecycleRequest #> @@ -48,7 +65,13 @@ function New-IdleRequest { [hashtable] $IdentityKeys = @{}, [Parameter()] - [hashtable] $DesiredState = @{}, + [hashtable] $Intent, + + [Parameter()] + [hashtable] $Context, + + [Parameter()] + [hashtable] $DesiredState, [Parameter()] [hashtable] $Changes diff --git a/tests/Core/New-IdleRequest.Tests.ps1 b/tests/Core/New-IdleRequest.Tests.ps1 index c71ed887..045bbf5e 100644 --- a/tests/Core/New-IdleRequest.Tests.ps1 +++ b/tests/Core/New-IdleRequest.Tests.ps1 @@ -32,6 +32,14 @@ Describe 'New-IdleRequest' { $req.IdentityKeys.Count | Should -Be 0 $req.DesiredState.Count | Should -Be 0 } + + It 'defaults Intent and Context to empty hashtables when omitted' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $req.Intent | Should -BeOfType 'hashtable' + $req.Context | Should -BeOfType 'hashtable' + $req.Intent.Count | Should -Be 0 + $req.Context.Count | Should -Be 0 + } } Context 'Optional properties' { @@ -65,6 +73,53 @@ Describe 'New-IdleRequest' { $req.Actor | Should -Be 'alice@contoso.com' } } + + Context 'Intent parameter' { + It 'accepts -Intent and populates Intent property' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Department = 'Engineering' } + $req.Intent | Should -BeOfType 'hashtable' + $req.Intent.Department | Should -Be 'Engineering' + } + + It 'mirrors Intent value into DesiredState for backward compatibility' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Title = 'Engineer' } + $req.DesiredState.Title | Should -Be 'Engineer' + } + } + + Context 'Context parameter' { + It 'accepts -Context and populates Context property' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Context @{ Identity = @{ ObjectId = 'abc-123' } } + $req.Context | Should -BeOfType 'hashtable' + $req.Context.Identity.ObjectId | Should -Be 'abc-123' + } + } + + Context 'DesiredState transition window' { + It 'maps DesiredState to Intent when only DesiredState is provided' { + $req = $null + $warnings = $null + $warnings = & { + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Department = 'HR' } -WarningVariable w 3>&1 + $w + } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Department = 'HR' } 3>$null + $req.Intent.Department | Should -Be 'HR' + } + + It 'emits a deprecation warning when DesiredState is used' { + $warningMessage = $null + New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Foo = 'Bar' } -WarningVariable warningMessage 3>$null | Out-Null + $warningMessage | Should -Not -BeNullOrEmpty + $warningMessage | Should -Match 'deprecated' + $warningMessage | Should -Match 'Intent' + } + + It 'rejects providing both DesiredState and Intent' { + { New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ A = '1' } -Intent @{ B = '2' } } | + Should -Throw -ExpectedMessage "*'DesiredState' is deprecated*" + } + } } Describe 'New-IdleRequest - data-only validation' { @@ -79,7 +134,7 @@ Describe 'New-IdleRequest - data-only validation' { catch { $_.Exception | Should -BeOfType ([System.ArgumentException]) $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' - $_.Exception.Message | Should -Match 'DesiredState' + $_.Exception.Message | Should -Match 'Intent' } } @@ -95,10 +150,26 @@ Describe 'New-IdleRequest - data-only validation' { catch { $_.Exception | Should -BeOfType ([System.ArgumentException]) $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' - $_.Exception.Message | Should -Match 'DesiredState' + $_.Exception.Message | Should -Match 'Intent' } } + It 'rejects ScriptBlock in Intent when provided' { + { + New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ + Attributes = @{ Department = { 'IT' } } + } + } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' + } + + It 'rejects ScriptBlock in Context when provided' { + { + New-IdleRequest -LifecycleEvent 'Joiner' -Context @{ + Identity = @{ Value = { 'NOPE' } } + } + } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' + } + It 'rejects ScriptBlock in Changes when provided' { try { New-IdleRequest -LifecycleEvent 'Joiner' -Changes @{ @@ -120,4 +191,44 @@ Describe 'New-IdleRequest - data-only validation' { } } +Describe 'New-IdlePlan - Request.Identity rejection' { + BeforeAll { + function global:Invoke-IdleTestNoopStep { + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateNotNull()][object] $Context, + [Parameter(Mandatory)][ValidateNotNull()][object] $Step + ) + return [pscustomobject]@{ + PSTypeName = 'IdLE.StepResult' + Name = [string]$Step.Name + Type = [string]$Step.Type + Status = 'Completed' + Error = $null + } + } + } + + AfterAll { + Remove-Item -Path 'Function:\Invoke-IdleTestNoopStep' -ErrorAction SilentlyContinue + } + + It 'rejects a request object that contains an Identity property' { + $badRequest = [pscustomobject]@{ + PSTypeName = 'IdLE.LifecycleRequest' + LifecycleEvent = 'Joiner' + CorrelationId = [guid]::NewGuid().ToString() + Identity = @{ ObjectId = 'abc-123' } + } + + $wfPath = Join-Path $PSScriptRoot '..' 'fixtures/workflows/template-tests/template-simple.psd1' + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $badRequest -Providers $providers } | + Should -Throw -ExpectedMessage "*must not contain property 'Identity'*" + } +} diff --git a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 index 39aa3060..122f2257 100644 --- a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 +++ b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 @@ -74,6 +74,42 @@ Describe 'Template Substitution' { $plan.Steps[0].With.Department | Should -Be 'Engineering' } + + It 'resolves Request.Intent placeholder' { + $wfPath = Get-TemplateTestFixture 'template-intent' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ + Department = 'Engineering' + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Department | Should -Be 'Engineering' + } + + It 'resolves Request.Context placeholder' { + $wfPath = Get-TemplateTestFixture 'template-context' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Context @{ + Identity = @{ ObjectId = 'obj-abc-123' } + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.ObjectId | Should -Be 'obj-abc-123' + } } Context 'Multiple placeholders in one string' { diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index 2e5e8ded..b67bebaa 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -79,6 +79,12 @@ function New-IdleTestRequest { [Parameter()] [hashtable] $IdentityKeys, + [Parameter()] + [hashtable] $Intent, + + [Parameter()] + [hashtable] $Context, + [Parameter()] [hashtable] $DesiredState, @@ -95,10 +101,12 @@ function New-IdleTestRequest { $params = @{ LifecycleEvent = $LifecycleEvent } if ($PSBoundParameters.ContainsKey('IdentityKeys')) { $params.IdentityKeys = $IdentityKeys } + if ($PSBoundParameters.ContainsKey('Intent')) { $params.Intent = $Intent } + if ($PSBoundParameters.ContainsKey('Context')) { $params.Context = $Context } if ($PSBoundParameters.ContainsKey('DesiredState')) { $params.DesiredState = $DesiredState } - if ($PSBoundParameters.ContainsKey('Changes')) { $params.Changes = $Changes } + if ($PSBoundParameters.ContainsKey('Changes')) { $params.Changes = $Changes } if ($PSBoundParameters.ContainsKey('CorrelationId')) { $params.CorrelationId = $CorrelationId } - if ($PSBoundParameters.ContainsKey('Actor')) { $params.Actor = $Actor } + if ($PSBoundParameters.ContainsKey('Actor')) { $params.Actor = $Actor } return New-IdleRequest @params } diff --git a/tests/fixtures/plan-export/expected/plan-export.json b/tests/fixtures/plan-export/expected/plan-export.json index 5f2e5876..348a2ad7 100644 --- a/tests/fixtures/plan-export/expected/plan-export.json +++ b/tests/fixtures/plan-export/expected/plan-export.json @@ -11,7 +11,7 @@ "identityKeys": { "userId": "jdoe" }, - "desiredState": { + "intent": { "department": "IT" }, "changes": null diff --git a/tests/fixtures/workflows/template-tests/template-context.psd1 b/tests/fixtures/workflows/template-tests/template-context.psd1 new file mode 100644 index 00000000..93b7e423 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-context.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Context' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + ObjectId = '{{Request.Context.Identity.ObjectId}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-intent.psd1 b/tests/fixtures/workflows/template-tests/template-intent.psd1 new file mode 100644 index 00000000..fb8f9128 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-intent.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Intent' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Department = '{{Request.Intent.Department}}' + } + } + ) +} From b03f1ad97eb2c7c6964395b3a9da7e12454ecbad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 13:55:40 +0000 Subject: [PATCH 3/9] Address code review: fix test clarity and example variable naming Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- examples/Invoke-LeaverWithManagerOOF.ps1 | 12 ++++++------ tests/Core/New-IdleRequest.Tests.ps1 | 6 ------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/examples/Invoke-LeaverWithManagerOOF.ps1 b/examples/Invoke-LeaverWithManagerOOF.ps1 index 3dfc195a..d3b8fd34 100644 --- a/examples/Invoke-LeaverWithManagerOOF.ps1 +++ b/examples/Invoke-LeaverWithManagerOOF.ps1 @@ -115,15 +115,17 @@ switch ($DirectorySource) { # 2. Build lifecycle request with caller-provided Intent Write-Host "==> Building lifecycle request..." -ForegroundColor Cyan -$intent = @{} +$enrichedIntent = @{ + UserPrincipalName = $UserPrincipalName +} if ($managerInfo) { - $intent['Manager'] = $managerInfo + $enrichedIntent['Manager'] = $managerInfo } else { # Fallback: use generic support contact Write-Warning "No manager found; using generic support contact in OOF message." - $intent['Manager'] = @{ + $enrichedIntent['Manager'] = @{ DisplayName = 'IT Support' Mail = 'support@contoso.com' } @@ -132,9 +134,7 @@ else { $request = New-IdleRequest ` -LifecycleEvent 'Leaver' ` -Actor $env:USERNAME ` - -Intent (@{ - UserPrincipalName = $UserPrincipalName - } + $intent) + -Intent $enrichedIntent Write-Host " Request CorrelationId: $($request.CorrelationId)" -ForegroundColor Gray diff --git a/tests/Core/New-IdleRequest.Tests.ps1 b/tests/Core/New-IdleRequest.Tests.ps1 index 045bbf5e..54baf338 100644 --- a/tests/Core/New-IdleRequest.Tests.ps1 +++ b/tests/Core/New-IdleRequest.Tests.ps1 @@ -97,12 +97,6 @@ Describe 'New-IdleRequest' { Context 'DesiredState transition window' { It 'maps DesiredState to Intent when only DesiredState is provided' { - $req = $null - $warnings = $null - $warnings = & { - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Department = 'HR' } -WarningVariable w 3>&1 - $w - } $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Department = 'HR' } 3>$null $req.Intent.Department | Should -Be 'HR' } From 1690fc600afe0a4f691fae896f08ec58f39d5583 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:14:40 +0000 Subject: [PATCH 4/9] Remove all DesiredState backward compatibility - no aliases, no mappings, no transition logic Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- docs/reference/cmdlets/New-IdleRequest.md | 29 +---- docs/use/workflows.md | 4 +- .../ConvertTo-IdlePlanExportObject.ps1 | 4 +- src/IdLE.Core/Private/Get-IdleValueByPath.ps1 | 2 +- .../Private/IdleLifecycleRequest.ps1 | 8 +- .../Private/Resolve-IdleTemplateString.ps1 | 38 +----- src/IdLE.Core/Public/New-IdlePlanObject.ps1 | 1 - .../Public/New-IdleRequestObject.ps1 | 36 +----- ...nvoke-IdleStepMailboxOutOfOfficeEnsure.ps1 | 10 +- src/IdLE.Steps.Mailbox/README.md | 2 +- src/IdLE/Public/New-IdleRequest.ps1 | 14 +- tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 | 6 +- tests/Core/CapabilityDeprecation.Tests.ps1 | 4 +- tests/Core/Export-IdlePlan.Tests.ps1 | 2 +- ...Invoke-IdlePlan.MailboxTemplates.Tests.ps1 | 20 +-- tests/Core/New-IdleRequest.Tests.ps1 | 76 ++--------- .../Resolve-IdleWorkflowTemplates.Tests.ps1 | 122 +++++------------- tests/_testHelpers.ps1 | 4 - .../template-tests/template-array-value.psd1 | 2 +- .../template-tests/template-array.psd1 | 4 +- .../template-tests/template-boolean.psd1 | 2 +- .../template-tests/template-desiredstate.psd1 | 2 +- .../template-escaped-mixed.psd1 | 2 +- .../template-tests/template-hashtable.psd1 | 2 +- .../template-tests/template-input-alias.psd1 | 2 +- .../template-tests/template-input-exists.psd1 | 2 +- .../template-tests/template-missing-path.psd1 | 2 +- .../template-mixed-boolean.psd1 | 2 +- .../template-tests/template-multiple.psd1 | 2 +- .../template-tests/template-nested-hash.psd1 | 4 +- .../template-tests/template-null-value.psd1 | 2 +- .../template-tests/template-numeric.psd1 | 2 +- .../template-tests/template-onfailure.psd1 | 4 +- .../template-tests/template-path-spaces.psd1 | 2 +- .../template-tests/template-path-special.psd1 | 2 +- .../template-pure-boolean-false.psd1 | 2 +- .../template-pure-boolean-true.psd1 | 2 +- .../template-pure-datetime.psd1 | 2 +- .../template-tests/template-pure-guid.psd1 | 2 +- .../template-tests/template-pure-integer.psd1 | 2 +- .../template-tests/template-simple.psd1 | 2 +- .../template-unbalanced-close.psd1 | 2 +- .../template-unbalanced-open.psd1 | 2 +- 43 files changed, 108 insertions(+), 330 deletions(-) diff --git a/docs/reference/cmdlets/New-IdleRequest.md b/docs/reference/cmdlets/New-IdleRequest.md index b860ac28..e6f73560 100644 --- a/docs/reference/cmdlets/New-IdleRequest.md +++ b/docs/reference/cmdlets/New-IdleRequest.md @@ -15,7 +15,7 @@ Creates a lifecycle request object. ``` New-IdleRequest [-LifecycleEvent] [[-CorrelationId] ] [[-Actor] ] [[-IdentityKeys] ] [[-Intent] ] [[-Context] ] - [[-DesiredState] ] [[-Changes] ] + [[-Changes] ] [-ProgressAction ] [] ``` @@ -27,11 +27,6 @@ CorrelationId is generated if missing. Actor is optional. Changes is optional and stays $null when omitted. -Transition window (DesiredState → Intent): -- Providing only -DesiredState maps it to -Intent and emits a deprecation warning. -- Providing both -DesiredState and -Intent fails fast with a validation error. -- After the transition window, -DesiredState support will be removed. - ## EXAMPLES ### EXAMPLE 1 @@ -113,7 +108,6 @@ Accept wildcard characters: False ### -Intent A hashtable containing the caller-provided action inputs for the workflow (attributes, entitlements, operator flags, etc.). -Canonical replacement for DesiredState. ```yaml Type: Hashtable @@ -145,25 +139,6 @@ Accept pipeline input: False Accept wildcard characters: False ``` -### -DesiredState -Deprecated. -Use -Intent instead. -Providing only -DesiredState maps it to -Intent and emits -a deprecation warning. -Providing both -DesiredState and -Intent is an error. - -```yaml -Type: Hashtable -Parameter Sets: (All) -Aliases: - -Required: False -Position: 7 -Default value: None -Accept pipeline input: False -Accept wildcard characters: False -``` - ### -Changes Optional hashtable describing changes (typically used for Mover lifecycle events). @@ -173,7 +148,7 @@ Parameter Sets: (All) Aliases: Required: False -Position: 8 +Position: 7 Default value: None Accept pipeline input: False Accept wildcard characters: False diff --git a/docs/use/workflows.md b/docs/use/workflows.md index 8dab8d81..72a44e8b 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -84,15 +84,13 @@ For security, only these path roots are permitted: | Root | Description | | ---- | ----------- | -| `Request.Intent.*` | Caller-provided action inputs (canonical) | +| `Request.Intent.*` | Caller-provided action inputs | | `Request.Context.*` | Read-only associated context (host/resolver-provided) | | `Request.IdentityKeys.*` | Identifiers of the target identity | | `Request.Changes.*` | Explicit deltas (Mover events) | | `Request.LifecycleEvent` | Lifecycle event type (e.g. `Joiner`) | | `Request.CorrelationId` | Stable correlation identifier | | `Request.Actor` | Originator of the request | -| `Request.DesiredState.*` | Deprecated alias for `Request.Intent.*` (transition window) | -| `Request.Input.*` | Legacy alias for `Request.Intent.*` when no `Input` property exists | ### Pure vs. mixed placeholders diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index a1d8c083..ba4a7834 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -80,10 +80,10 @@ function ConvertTo-IdlePlanExportObject { $requestInput = Get-FirstPropertyValue -Object $request -Names @('Input', 'Data', 'Payload', 'Attributes') if ($null -eq $requestInput) { - # IdLE lifecycle requests store business intent as IdentityKeys/Intent/DesiredState/Changes. + # IdLE lifecycle requests store business intent as IdentityKeys/Intent/Changes. # When present, export these as the canonical request.input payload. $identityKeys = Get-FirstPropertyValue -Object $request -Names @('IdentityKeys', 'IdentityKey', 'Keys') - $intent = Get-FirstPropertyValue -Object $request -Names @('Intent', 'DesiredState', 'TargetState') + $intent = Get-FirstPropertyValue -Object $request -Names @('Intent', 'TargetState') $changes = Get-FirstPropertyValue -Object $request -Names @('Changes', 'Delta') if ($null -ne $identityKeys -or $null -ne $intent -or $null -ne $changes) { diff --git a/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 b/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 index ecc34bc4..6c4359e8 100644 --- a/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 +++ b/src/IdLE.Core/Private/Get-IdleValueByPath.ps1 @@ -10,7 +10,7 @@ function Get-IdleValueByPath { [string] $Path ) - # Supports dotted property paths, e.g. "DesiredState.Department" + # Supports dotted property paths, e.g. "Intent.Department" $current = $Object foreach ($segment in ($Path -split '\.')) { if ($null -eq $current) { return $null } diff --git a/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 b/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 index 9b2ac9b8..514f670e 100644 --- a/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 +++ b/src/IdLE.Core/Private/IdleLifecycleRequest.ps1 @@ -2,16 +2,14 @@ # Actor is intentionally optional in V1 (see architecture). # Changes is optional and stays $null if not provided. # -# Intent - canonical caller-provided input block (replaces DesiredState). +# Intent - canonical caller-provided input block. # Context - read-only associated context provided by the host or resolvers. -# DesiredState - backward-compat alias mirroring Intent during the transition window. class IdleLifecycleRequest { [string] $LifecycleEvent [hashtable] $IdentityKeys [hashtable] $Intent [hashtable] $Context - [hashtable] $DesiredState [hashtable] $Changes [string] $CorrelationId [string] $Actor @@ -53,10 +51,6 @@ class IdleLifecycleRequest { $this.Context = @{} } - # DesiredState mirrors Intent for backward compatibility during the transition window. - # Templates that reference Request.DesiredState.* continue to resolve correctly. - $this.DesiredState = $this.Intent - # Changes stays $null if not provided. If provided, it must be a hashtable. if ($null -ne $this.Changes -and $this.Changes -isnot [hashtable]) { throw [System.ArgumentException]::new('Changes must be a hashtable when provided.', 'Changes') diff --git a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 index 35167c06..a5e8c1aa 100644 --- a/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 +++ b/src/IdLE.Core/Private/Resolve-IdleTemplateString.ps1 @@ -15,8 +15,6 @@ function Resolve-IdleTemplateString { Allowed roots (security boundary): - Request.Intent.* (canonical caller-provided action inputs) - Request.Context.* (read-only associated context) - - Request.Input.* (aliased to Request.Intent.* if Input does not exist) - - Request.DesiredState.* (deprecated alias for Request.Intent.*; supported during transition window) - Request.IdentityKeys.* - Request.Changes.* - Request.LifecycleEvent @@ -74,7 +72,7 @@ function Resolve-IdleTemplateString { # Define validation constants used in multiple paths $pathValidationPattern = '^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)*$' - $allowedRoots = @('Request.Intent', 'Request.Context', 'Request.Input', 'Request.DesiredState', 'Request.IdentityKeys', 'Request.Changes', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor') + $allowedRoots = @('Request.Intent', 'Request.Context', 'Request.IdentityKeys', 'Request.Changes', 'Request.LifecycleEvent', 'Request.CorrelationId', 'Request.Actor') # Helper function to validate path pattern $validatePath = { @@ -109,39 +107,9 @@ function Resolve-IdleTemplateString { $resolvePath = { param([string]$Path) - # Handle Request.Input.* alias to Request.Intent.* (or Request.DesiredState.* for backward compat) - $targetPath = $Path - $hasInputProperty = $false - if ($Request.PSObject.Properties['Input']) { - $hasInputProperty = $true - } - - if ($Path.StartsWith('Request.Input.')) { - if (-not $hasInputProperty) { - # Alias to Intent (canonical); DesiredState mirrors Intent so either works - $targetPath = $Path -replace '^Request\.Input\.', 'Request.Intent.' - } - } - elseif ($Path -eq 'Request.Input') { - if (-not $hasInputProperty) { - $targetPath = 'Request.Intent' - } - } - - # Handle Request.DesiredState.* alias to Request.Intent.* when DesiredState is absent - # but Intent is present. This supports requests built with custom PSObjects that only - # expose Intent (no DesiredState backward-compat mirror). - if ($targetPath.StartsWith('Request.DesiredState.') -or $targetPath -eq 'Request.DesiredState') { - $hasDesiredStateProperty = $null -ne $Request.PSObject.Properties['DesiredState'] - $hasIntentProperty = $null -ne $Request.PSObject.Properties['Intent'] - if (-not $hasDesiredStateProperty -and $hasIntentProperty) { - $targetPath = $targetPath -replace '^Request\.DesiredState', 'Request.Intent' - } - } - # Resolve the value (shared path resolver handles hashtables and objects) $contextWrapper = [pscustomobject]@{ Request = $Request } - $resolvedValue = Get-IdleValueByPath -Object $contextWrapper -Path $targetPath + $resolvedValue = Get-IdleValueByPath -Object $contextWrapper -Path $Path # Fail fast on null/missing values if ($null -eq $resolvedValue) { @@ -220,7 +188,7 @@ function Resolve-IdleTemplateString { # like \{{Request.Foo}} (invalid root) or \{{Request..Name}} (double dot) are still escaped to # literal {{, rather than flowing into template parsing and failing with path/root errors. $litOpenPlaceholder = [string][char]0xE001 - $backslashEscapePattern = '\\{{(?!Request\.(?:(?:Intent|Context|Input|DesiredState|IdentityKeys|Changes)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)}})' + $backslashEscapePattern = '\\{{(?!Request\.(?:(?:Intent|Context|IdentityKeys|Changes)(?:\.[A-Za-z0-9_]+)*|LifecycleEvent|CorrelationId|Actor)}})' $normalizedValue = if ($stringValue -match '\\{{') { [regex]::Replace($stringValue, $backslashEscapePattern, $litOpenPlaceholder) } else { diff --git a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 index 7fd63739..69be1fa5 100644 --- a/src/IdLE.Core/Public/New-IdlePlanObject.ps1 +++ b/src/IdLE.Core/Public/New-IdlePlanObject.ps1 @@ -71,7 +71,6 @@ function New-IdlePlanObject { IdentityKeys = if ($reqProps -contains 'IdentityKeys') { Copy-IdleDataObject -Value $Request.IdentityKeys } else { $null } Intent = if ($reqProps -contains 'Intent') { Copy-IdleDataObject -Value $Request.Intent } else { $null } Context = if ($reqProps -contains 'Context') { Copy-IdleDataObject -Value $Request.Context } 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 } } diff --git a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 index 3724b9d1..573759d0 100644 --- a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 +++ b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 @@ -10,11 +10,6 @@ function New-IdleRequestObject { The function validates that no ScriptBlocks are present in the input data (IdentityKeys, Intent, Context, Changes) to enforce the data-only configuration principle. Input hashtables are cloned to prevent external mutation after object creation. - - Transition window (DesiredState → Intent): - - Providing only -DesiredState maps it to -Intent and emits a deprecation warning. - - Providing both -DesiredState and -Intent fails fast with a validation error. - - After the transition window, -DesiredState support will be removed. CorrelationId is preserved if provided; otherwise, the IdleLifecycleRequest class generates a new GUID. Actor is optional and not required by the core engine. @@ -38,18 +33,13 @@ function New-IdleRequestObject { .PARAMETER Intent A hashtable containing the caller-provided action inputs for the workflow (attributes, entitlements, operator flags, etc.). Defaults to an empty hashtable if not provided. - Must not contain ScriptBlocks. Canonical replacement for DesiredState. + Must not contain ScriptBlocks. .PARAMETER Context A hashtable containing read-only associated context provided by the host or resolvers (e.g. identity snapshots, device hints). Defaults to an empty hashtable if not provided. Must not contain ScriptBlocks. Must not be treated as mutable state within IdLE. - .PARAMETER DesiredState - Deprecated. Use -Intent instead. A hashtable describing the desired state for the identity. - During the transition window, if only -DesiredState is provided it is mapped to -Intent - and a deprecation warning is emitted. Providing both -DesiredState and -Intent is an error. - .PARAMETER Changes Optional hashtable describing changes (typically used for Mover lifecycle events to indicate what changed from the previous state). Remains $null when omitted. Must not contain ScriptBlocks. @@ -105,33 +95,11 @@ function New-IdleRequestObject { [Parameter()] [hashtable] $Context, - [Parameter()] - [hashtable] $DesiredState, - [Parameter()] [hashtable] $Changes ) - # Transition window: DesiredState → Intent migration. - $intentProvided = $PSBoundParameters.ContainsKey('Intent') - $desiredStateProvided = $PSBoundParameters.ContainsKey('DesiredState') - - if ($intentProvided -and $desiredStateProvided) { - throw [System.ArgumentException]::new( - "Both 'Intent' and 'DesiredState' were provided. 'DesiredState' is deprecated. Provide only 'Intent'.", - 'DesiredState' - ) - } - - if ($desiredStateProvided -and -not $intentProvided) { - # Map DesiredState → Intent and emit a structured deprecation warning. - Write-Warning ("IdLE deprecation: The 'DesiredState' parameter is deprecated and will be removed in a future release. " + - "Please migrate to '-Intent' (e.g. 'New-IdleRequest -LifecycleEvent ... -Intent @{...}'). " + - "For this request, 'DesiredState' has been mapped to 'Intent' automatically.") - $Intent = $DesiredState - } - - # Default to empty hashtables when neither was provided. + # Default to empty hashtables when not provided. if ($null -eq $Intent) { $Intent = @{} } if ($null -eq $Context) { $Context = @{} } diff --git a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 index 0ce48612..830d718a 100644 --- a/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 +++ b/src/IdLE.Steps.Mailbox/Public/Invoke-IdleStepMailboxOutOfOfficeEnsure.ps1 @@ -64,7 +64,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { Type = 'IdLE.Step.Mailbox.EnsureOutOfOffice' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } Config = @{ Mode = 'Enabled' InternalMessage = 'This person is no longer with the organization. For assistance, please contact their manager or the main office.' @@ -127,7 +127,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { .EXAMPLE # Template usage with dynamic manager attributes (Leaver scenario): # Note: Templates are resolved during planning against the request object. - # Host must enrich request.DesiredState with manager data before calling New-IdlePlan. + # Host must enrich request.Intent with manager data before calling New-IdlePlan. # Host-side enrichment (example): # $user = Get-ADUser -Identity 'max.power' -Properties Manager @@ -140,7 +140,7 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { # Mail = 'servicedesk@contoso.com' # } # } - # $req = New-IdleRequest -LifecycleEvent 'Leaver' -Actor $env:USERNAME -DesiredState @{ + # $req = New-IdleRequest -LifecycleEvent 'Leaver' -Actor $env:USERNAME -Intent @{ # Manager = @{ DisplayName = $mgr.DisplayName; Mail = $mgr.Mail } # } @@ -153,8 +153,8 @@ function Invoke-IdleStepMailboxOutOfOfficeEnsure { IdentityKey = 'max.power@contoso.com' Config = @{ Mode = 'Enabled' - InternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.DisplayName}} ({{Request.DesiredState.Manager.Mail}}).' - ExternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.DesiredState.Manager.Mail}}.' + InternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.Intent.Manager.DisplayName}} ({{Request.Intent.Manager.Mail}}).' + ExternalMessage = 'This mailbox is no longer monitored. Please contact {{Request.Intent.Manager.Mail}}.' ExternalAudience = 'All' } } diff --git a/src/IdLE.Steps.Mailbox/README.md b/src/IdLE.Steps.Mailbox/README.md index 1a700f7e..77f3807b 100644 --- a/src/IdLE.Steps.Mailbox/README.md +++ b/src/IdLE.Steps.Mailbox/README.md @@ -11,7 +11,7 @@ Provider-agnostic mailbox step pack for IdLE. Type = 'IdLE.Step.Mailbox.EnsureType' With = @{ Provider = 'ExchangeOnline' - IdentityKey = @{ ValueFrom = 'Request.Input.UserPrincipalName' } + IdentityKey = @{ ValueFrom = 'Request.Intent.UserPrincipalName' } MailboxType = 'Shared' } } diff --git a/src/IdLE/Public/New-IdleRequest.ps1 b/src/IdLE/Public/New-IdleRequest.ps1 index 62881bd7..4aeeb035 100644 --- a/src/IdLE/Public/New-IdleRequest.ps1 +++ b/src/IdLE/Public/New-IdleRequest.ps1 @@ -8,11 +8,6 @@ function New-IdleRequest { (e.g. Joiner/Mover/Leaver). CorrelationId is generated if missing. Actor is optional. Changes is optional and stays $null when omitted. - Transition window (DesiredState → Intent): - - Providing only -DesiredState maps it to -Intent and emits a deprecation warning. - - Providing both -DesiredState and -Intent fails fast with a validation error. - - After the transition window, -DesiredState support will be removed. - .PARAMETER LifecycleEvent The lifecycle event name (e.g. Joiner, Mover, Leaver). @@ -27,16 +22,12 @@ function New-IdleRequest { .PARAMETER Intent A hashtable containing the caller-provided action inputs for the workflow (attributes, - entitlements, operator flags, etc.). Canonical replacement for DesiredState. + entitlements, operator flags, etc.). .PARAMETER Context A hashtable containing read-only associated context provided by the host or resolvers (e.g. identity snapshots, device hints). Must not be treated as mutable state within IdLE. - .PARAMETER DesiredState - Deprecated. Use -Intent instead. Providing only -DesiredState maps it to -Intent and emits - a deprecation warning. Providing both -DesiredState and -Intent is an error. - .PARAMETER Changes Optional hashtable describing changes (typically used for Mover lifecycle events). @@ -70,9 +61,6 @@ function New-IdleRequest { [Parameter()] [hashtable] $Context, - [Parameter()] - [hashtable] $DesiredState, - [Parameter()] [hashtable] $Changes ) diff --git a/tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 b/tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 index 9944c117..6aeb5204 100644 --- a/tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 +++ b/tests/Core/Assert-IdleNoScriptBlock.Tests.ps1 @@ -217,11 +217,11 @@ Describe 'Assert-IdleNoScriptBlock' { Should -Throw -ExceptionType ([System.ArgumentException]) -ExpectedMessage '*ScriptBlocks are not allowed*' } - It 'rejects lifecycle request with ScriptBlock in DesiredState' { + It 'rejects lifecycle request with ScriptBlock in Intent' { $request = @{ LifecycleEvent = 'Joiner' CorrelationId = 'test-123' - DesiredState = @{ + Intent = @{ Identity = @{ BadProperty = { Get-Credential } } @@ -229,7 +229,7 @@ Describe 'Assert-IdleNoScriptBlock' { } { Assert-IdleNoScriptBlock -InputObject $request -Path 'Request' } | - Should -Throw -ExceptionType ([System.ArgumentException]) -ExpectedMessage '*ScriptBlocks are not allowed*Request.DesiredState.Identity.BadProperty*' + Should -Throw -ExceptionType ([System.ArgumentException]) -ExpectedMessage '*ScriptBlocks are not allowed*Request.Intent.Identity.BadProperty*' } } diff --git a/tests/Core/CapabilityDeprecation.Tests.ps1 b/tests/Core/CapabilityDeprecation.Tests.ps1 index ad99a6b8..103a44d1 100644 --- a/tests/Core/CapabilityDeprecation.Tests.ps1 +++ b/tests/Core/CapabilityDeprecation.Tests.ps1 @@ -32,7 +32,7 @@ Describe 'Capability Deprecation and Migration' { # Verify the workflow file exists $wfPath | Should -Exist - $req = New-IdleTestRequest -LifecycleEvent 'Leaver' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Leaver' -Intent @{ UserPrincipalName = 'leaver@contoso.com' Manager = @{ DisplayName = 'IT Support' @@ -77,7 +77,7 @@ Describe 'Capability Deprecation and Migration' { # Use a real workflow file $wfPath = Join-Path $PSScriptRoot '..' '..' 'examples' 'workflows' 'templates' 'exo-leaver.psd1' - $req = New-IdleTestRequest -LifecycleEvent 'Leaver' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Leaver' -Intent @{ UserPrincipalName = 'leaver@contoso.com' Manager = @{ DisplayName = 'IT Support' diff --git a/tests/Core/Export-IdlePlan.Tests.ps1 b/tests/Core/Export-IdlePlan.Tests.ps1 index 8a550491..b8482b14 100644 --- a/tests/Core/Export-IdlePlan.Tests.ps1 +++ b/tests/Core/Export-IdlePlan.Tests.ps1 @@ -30,7 +30,7 @@ Describe 'Export-IdlePlan' { -LifecycleEvent 'Joiner' ` -CorrelationId $cid ` -IdentityKeys ([ordered]@{ userId = 'jdoe' }) ` - -DesiredState ([ordered]@{ department = 'IT' }) + -Intent ([ordered]@{ department = 'IT' }) $providers = @{ Dummy = $true diff --git a/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 b/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 index 83328af9..49391b06 100644 --- a/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 +++ b/tests/Core/Invoke-IdlePlan.MailboxTemplates.Tests.ps1 @@ -68,8 +68,8 @@ Describe 'Mailbox OutOfOffice step - template resolution' { IdentityKey = 'user@contoso.com' Config = @{ Mode = 'Enabled' - InternalMessage = 'Please contact {{Request.DesiredState.Manager.DisplayName}} at {{Request.DesiredState.Manager.Mail}}.' - ExternalMessage = 'Please contact {{Request.DesiredState.Manager.Mail}}.' + InternalMessage = 'Please contact {{Request.Intent.Manager.DisplayName}} at {{Request.Intent.Manager.Mail}}.' + ExternalMessage = 'Please contact {{Request.Intent.Manager.Mail}}.' ExternalAudience = 'All' } } @@ -81,7 +81,7 @@ Describe 'Mailbox OutOfOffice step - template resolution' { $req = New-IdleTestRequest ` -LifecycleEvent 'Leaver' ` -Actor 'admin@contoso.com' ` - -DesiredState @{ + -Intent @{ Manager = @{ DisplayName = 'Jane Smith' Mail = 'jane.smith@contoso.com' @@ -107,7 +107,7 @@ Describe 'Mailbox OutOfOffice step - template resolution' { $mailbox.OOFExternalMessage | Should -Be 'Please contact jane.smith@contoso.com.' } - It 'resolves nested template variables from Request.DesiredState' { + It 'resolves nested template variables from Request.Intent' { $wfPath = New-IdleTestWorkflowFile -FileName 'oof-nested-templates.psd1' -Content @' @{ Name = 'OOF with Nested Templates' @@ -121,8 +121,8 @@ Describe 'Mailbox OutOfOffice step - template resolution' { IdentityKey = 'user@contoso.com' Config = @{ Mode = 'Enabled' - InternalMessage = 'User has left. Contact: {{Request.DesiredState.Handover.Name}} ({{Request.DesiredState.Handover.Email}})' - ExternalMessage = 'For assistance: {{Request.DesiredState.Handover.Email}}' + InternalMessage = 'User has left. Contact: {{Request.Intent.Handover.Name}} ({{Request.Intent.Handover.Email}})' + ExternalMessage = 'For assistance: {{Request.Intent.Handover.Email}}' } } } @@ -133,7 +133,7 @@ Describe 'Mailbox OutOfOffice step - template resolution' { $req = New-IdleTestRequest ` -LifecycleEvent 'Leaver' ` -Actor 'admin@contoso.com' ` - -DesiredState @{ + -Intent @{ Handover = @{ Name = 'Bob Johnson' Email = 'bob.johnson@contoso.com' @@ -160,8 +160,8 @@ Describe 'Mailbox OutOfOffice step - template resolution' { IdentityKey = 'user@contoso.com' Config = @{ Mode = 'Enabled' - InternalMessage = 'Contact {{Request.DesiredState.TeamLead.Name}}' - ExternalMessage = 'Email {{Request.DesiredState.TeamLead.Email}}' + InternalMessage = 'Contact {{Request.Intent.TeamLead.Name}}' + ExternalMessage = 'Email {{Request.Intent.TeamLead.Email}}' } } } @@ -172,7 +172,7 @@ Describe 'Mailbox OutOfOffice step - template resolution' { $req = New-IdleTestRequest ` -LifecycleEvent 'Leaver' ` -Actor 'admin@contoso.com' ` - -DesiredState @{ + -Intent @{ TeamLead = @{ Name = 'Alice Brown' Email = 'alice.brown@contoso.com' diff --git a/tests/Core/New-IdleRequest.Tests.ps1 b/tests/Core/New-IdleRequest.Tests.ps1 index 54baf338..647c7acd 100644 --- a/tests/Core/New-IdleRequest.Tests.ps1 +++ b/tests/Core/New-IdleRequest.Tests.ps1 @@ -25,12 +25,10 @@ Describe 'New-IdleRequest' { $req.CorrelationId | Should -Be $cid } - It 'defaults IdentityKeys and DesiredState to empty hashtables when omitted' { + It 'defaults IdentityKeys to an empty hashtable when omitted' { $req = New-IdleRequest -LifecycleEvent 'Joiner' $req.IdentityKeys | Should -BeOfType 'hashtable' - $req.DesiredState | Should -BeOfType 'hashtable' $req.IdentityKeys.Count | Should -Be 0 - $req.DesiredState.Count | Should -Be 0 } It 'defaults Intent and Context to empty hashtables when omitted' { @@ -40,6 +38,11 @@ Describe 'New-IdleRequest' { $req.Intent.Count | Should -Be 0 $req.Context.Count | Should -Be 0 } + + It 'does not expose a DesiredState property' { + $req = New-IdleRequest -LifecycleEvent 'Joiner' + $req.PSObject.Properties.Name | Should -Not -Contain 'DesiredState' + } } Context 'Optional properties' { @@ -80,11 +83,6 @@ Describe 'New-IdleRequest' { $req.Intent | Should -BeOfType 'hashtable' $req.Intent.Department | Should -Be 'Engineering' } - - It 'mirrors Intent value into DesiredState for backward compatibility' { - $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Title = 'Engineer' } - $req.DesiredState.Title | Should -Be 'Engineer' - } } Context 'Context parameter' { @@ -94,65 +92,26 @@ Describe 'New-IdleRequest' { $req.Context.Identity.ObjectId | Should -Be 'abc-123' } } - - Context 'DesiredState transition window' { - It 'maps DesiredState to Intent when only DesiredState is provided' { - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Department = 'HR' } 3>$null - $req.Intent.Department | Should -Be 'HR' - } - - It 'emits a deprecation warning when DesiredState is used' { - $warningMessage = $null - New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Foo = 'Bar' } -WarningVariable warningMessage 3>$null | Out-Null - $warningMessage | Should -Not -BeNullOrEmpty - $warningMessage | Should -Match 'deprecated' - $warningMessage | Should -Match 'Intent' - } - - It 'rejects providing both DesiredState and Intent' { - { New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ A = '1' } -Intent @{ B = '2' } } | - Should -Throw -ExpectedMessage "*'DesiredState' is deprecated*" - } - } } Describe 'New-IdleRequest - data-only validation' { Context 'ScriptBlock rejection' { - It 'rejects ScriptBlock in DesiredState when provided' { - try { - New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + It 'rejects ScriptBlock in Intent when provided' { + { + New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Attributes = @{ Department = { 'IT' } } } - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception | Should -BeOfType ([System.ArgumentException]) - $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' - $_.Exception.Message | Should -Match 'Intent' - } + } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' } It 'rejects ScriptBlock nested in arrays' { - try { - New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + { + New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Entitlements = @( @{ Type = 'Group'; Value = 'APP-CRM-Users' } @{ Type = 'Custom'; Value = { 'NOPE' } } ) } - } - catch { - $_.Exception | Should -BeOfType ([System.ArgumentException]) - $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' - $_.Exception.Message | Should -Match 'Intent' - } - } - - It 'rejects ScriptBlock in Intent when provided' { - { - New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ - Attributes = @{ Department = { 'IT' } } - } } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*' } @@ -165,7 +124,7 @@ Describe 'New-IdleRequest - data-only validation' { } It 'rejects ScriptBlock in Changes when provided' { - try { + { New-IdleRequest -LifecycleEvent 'Joiner' -Changes @{ Attributes = @{ Department = @{ @@ -174,13 +133,7 @@ Describe 'New-IdleRequest - data-only validation' { } } } - throw 'Expected an exception but none was thrown.' - } - catch { - $_.Exception | Should -BeOfType ([System.ArgumentException]) - $_.Exception.Message | Should -Match 'ScriptBlocks are not allowed' - $_.Exception.Message | Should -Match 'Changes' - } + } | Should -Throw -ExpectedMessage '*ScriptBlocks are not allowed*Changes*' } } } @@ -225,4 +178,3 @@ Describe 'New-IdlePlan - Request.Identity rejection' { Should -Throw -ExpectedMessage "*must not contain property 'Identity'*" } } - diff --git a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 index 122f2257..257c2559 100644 --- a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 +++ b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 @@ -39,10 +39,10 @@ AfterAll { Describe 'Template Substitution' { Context 'Single placeholder substitution' { - It 'resolves a simple Request.Input placeholder' { + It 'resolves a simple Request.Intent placeholder' { $wfPath = Get-TemplateTestFixture 'template-simple' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ UserPrincipalName = 'jdoe@example.com' } $providers = @{ @@ -57,24 +57,6 @@ Describe 'Template Substitution' { $plan.Steps[0].With.UserName | Should -Be 'jdoe@example.com' } - It 'resolves Request.DesiredState placeholder directly' { - $wfPath = Get-TemplateTestFixture 'template-desiredstate' - - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ - Department = 'Engineering' - } - $providers = @{ - StepRegistry = @{ - 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' - } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - $plan.Steps[0].With.Department | Should -Be 'Engineering' - } - It 'resolves Request.Intent placeholder' { $wfPath = Get-TemplateTestFixture 'template-intent' @@ -116,7 +98,7 @@ Describe 'Template Substitution' { It 'resolves multiple placeholders in a single string' { $wfPath = Get-TemplateTestFixture 'template-multiple' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ DisplayName = 'John Doe' UserPrincipalName = 'jdoe@example.com' } @@ -137,7 +119,7 @@ Describe 'Template Substitution' { It 'resolves templates in nested hashtables' { $wfPath = Get-TemplateTestFixture 'template-nested-hash' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ DisplayName = 'Jane Smith' Mail = 'jsmith@example.com' } @@ -157,7 +139,7 @@ Describe 'Template Substitution' { It 'resolves templates in arrays' { $wfPath = Get-TemplateTestFixture 'template-array' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ PrimaryEmail = 'primary@example.com' SecondaryEmail = 'secondary@example.com' } @@ -179,7 +161,7 @@ Describe 'Template Substitution' { It 'throws on unbalanced opening brace' { $wfPath = Get-TemplateTestFixture 'template-unbalanced-open' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -192,7 +174,7 @@ Describe 'Template Substitution' { It 'throws on unbalanced closing brace' { $wfPath = Get-TemplateTestFixture 'template-unbalanced-close' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -207,7 +189,7 @@ Describe 'Template Substitution' { It 'throws on path with spaces' { $wfPath = Get-TemplateTestFixture 'template-path-spaces' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserName = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ UserName = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -220,7 +202,7 @@ Describe 'Template Substitution' { It 'throws on path with special characters' { $wfPath = Get-TemplateTestFixture 'template-path-special' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserName = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ UserName = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -235,7 +217,7 @@ Describe 'Template Substitution' { It 'throws when path does not exist' { $wfPath = Get-TemplateTestFixture 'template-missing-path' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -250,7 +232,7 @@ Describe 'Template Substitution' { It 'throws when resolved value is null' { $wfPath = Get-TemplateTestFixture 'template-null-value' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ NullField = $null } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ NullField = $null } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -265,7 +247,7 @@ Describe 'Template Substitution' { It 'throws when accessing Plan root' { $wfPath = Get-TemplateTestFixture 'template-plan-root' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -278,7 +260,7 @@ Describe 'Template Substitution' { It 'throws when accessing Providers root' { $wfPath = Get-TemplateTestFixture 'template-providers-root' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -291,7 +273,7 @@ Describe 'Template Substitution' { It 'throws when accessing Workflow root' { $wfPath = Get-TemplateTestFixture 'template-workflow-root' - $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -302,53 +284,11 @@ Describe 'Template Substitution' { } } - Context 'Request.Input alias behavior' { - It 'uses Request.Input when Input property exists' { - $wfPath = Get-TemplateTestFixture 'template-input-exists' - - # Create a request with explicit Input property - $req = [pscustomobject]@{ - PSTypeName = 'IdLE.LifecycleRequest' - LifecycleEvent = 'Joiner' - CorrelationId = [guid]::NewGuid().ToString() - Input = @{ Name = 'FromInput' } - DesiredState = @{ Name = 'FromDesiredState' } - } - - $providers = @{ - StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - $plan.Steps[0].With.Value | Should -Be 'FromInput' - } - - It 'aliases Request.Input to Request.DesiredState when Input does not exist' { - $wfPath = Get-TemplateTestFixture 'template-input-alias' - - # Use standard request without explicit Input property - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ - Name = 'FromDesiredState' - } - - $providers = @{ - StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } - StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') - } - - $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers - - $plan.Steps[0].With.Value | Should -Be 'FromDesiredState' - } - } - Context 'Escaping' { It 'handles escaped opening braces' { $wfPath = Get-TemplateTestFixture 'template-escaped' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -362,7 +302,7 @@ Describe 'Template Substitution' { It 'handles escaped braces mixed with templates' { $wfPath = Get-TemplateTestFixture 'template-escaped-mixed' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'TestName' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'TestName' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -393,7 +333,7 @@ Describe 'Template Substitution' { # A loose lookahead would let this through to template parsing → wrong "path not allowed" error. $wfPath = Get-TemplateTestFixture 'template-escaped-invalid-root' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -408,7 +348,7 @@ Describe 'Template Substitution' { It 'resolves templates in OnFailureSteps' { $wfPath = Get-TemplateTestFixture 'template-onfailure' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'John Doe' UserPrincipalName = 'jdoe@example.com' } @@ -430,7 +370,7 @@ Describe 'Template Substitution' { It 'allows Request.LifecycleEvent' { $wfPath = Get-TemplateTestFixture 'template-lifecycle-event' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -444,7 +384,7 @@ Describe 'Template Substitution' { It 'allows Request.CorrelationId' { $wfPath = Get-TemplateTestFixture 'template-correlation-id' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -458,7 +398,7 @@ Describe 'Template Substitution' { It 'allows Request.Actor' { $wfPath = Get-TemplateTestFixture 'template-actor' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Name = 'Test' } -Actor 'admin@example.com' + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } -Actor 'admin@example.com' $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -474,7 +414,7 @@ Describe 'Template Substitution' { It 'resolves numeric types to strings' { $wfPath = Get-TemplateTestFixture 'template-numeric' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserId = 12345 } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ UserId = 12345 } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -488,7 +428,7 @@ Describe 'Template Substitution' { It 'resolves boolean types to strings' { $wfPath = Get-TemplateTestFixture 'template-boolean' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ IsEnabled = $true } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ IsEnabled = $true } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -502,7 +442,7 @@ Describe 'Template Substitution' { It 'throws when resolving to a hashtable' { $wfPath = Get-TemplateTestFixture 'template-hashtable' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ UserData = @{ Name = 'John'; Age = 30 } } $providers = @{ @@ -517,7 +457,7 @@ Describe 'Template Substitution' { It 'throws when resolving to an array' { $wfPath = Get-TemplateTestFixture 'template-array-value' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Tags = @('tag1', 'tag2') } $providers = @{ @@ -534,7 +474,7 @@ Describe 'Template Substitution' { It 'preserves boolean false type for pure placeholder' { $wfPath = Get-TemplateTestFixture 'template-pure-boolean-false' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Enabled = $false } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Enabled = $false } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -551,7 +491,7 @@ Describe 'Template Substitution' { It 'preserves boolean true type for pure placeholder' { $wfPath = Get-TemplateTestFixture 'template-pure-boolean-true' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ IsActive = $true } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ IsActive = $true } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -566,7 +506,7 @@ Describe 'Template Substitution' { It 'preserves integer type for pure placeholder' { $wfPath = Get-TemplateTestFixture 'template-pure-integer' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ UserId = 12345 } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ UserId = 12345 } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -582,7 +522,7 @@ Describe 'Template Substitution' { $wfPath = Get-TemplateTestFixture 'template-pure-datetime' $testDate = Get-Date '2026-01-15T10:00:00' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ StartDate = $testDate } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ StartDate = $testDate } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -598,7 +538,7 @@ Describe 'Template Substitution' { $wfPath = Get-TemplateTestFixture 'template-pure-guid' $testGuid = [guid]::NewGuid() - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ ObjectId = $testGuid } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ ObjectId = $testGuid } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') @@ -613,7 +553,7 @@ Describe 'Template Substitution' { It 'converts to string for mixed template (string interpolation)' { $wfPath = Get-TemplateTestFixture 'template-mixed-boolean' - $req = New-IdleRequest -LifecycleEvent 'Joiner' -DesiredState @{ Enabled = $false } + $req = New-IdleRequest -LifecycleEvent 'Joiner' -Intent @{ Enabled = $false } $providers = @{ StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') diff --git a/tests/_testHelpers.ps1 b/tests/_testHelpers.ps1 index b67bebaa..195f3342 100644 --- a/tests/_testHelpers.ps1 +++ b/tests/_testHelpers.ps1 @@ -85,9 +85,6 @@ function New-IdleTestRequest { [Parameter()] [hashtable] $Context, - [Parameter()] - [hashtable] $DesiredState, - [Parameter()] [hashtable] $Changes, @@ -103,7 +100,6 @@ function New-IdleTestRequest { if ($PSBoundParameters.ContainsKey('IdentityKeys')) { $params.IdentityKeys = $IdentityKeys } if ($PSBoundParameters.ContainsKey('Intent')) { $params.Intent = $Intent } if ($PSBoundParameters.ContainsKey('Context')) { $params.Context = $Context } - if ($PSBoundParameters.ContainsKey('DesiredState')) { $params.DesiredState = $DesiredState } if ($PSBoundParameters.ContainsKey('Changes')) { $params.Changes = $Changes } if ($PSBoundParameters.ContainsKey('CorrelationId')) { $params.CorrelationId = $CorrelationId } if ($PSBoundParameters.ContainsKey('Actor')) { $params.Actor = $Actor } diff --git a/tests/fixtures/workflows/template-tests/template-array-value.psd1 b/tests/fixtures/workflows/template-tests/template-array-value.psd1 index 312ebaed..08787e22 100644 --- a/tests/fixtures/workflows/template-tests/template-array-value.psd1 +++ b/tests/fixtures/workflows/template-tests/template-array-value.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.Tags}}' + Value = '{{Request.Intent.Tags}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-array.psd1 b/tests/fixtures/workflows/template-tests/template-array.psd1 index 1a26a4a0..39daca96 100644 --- a/tests/fixtures/workflows/template-tests/template-array.psd1 +++ b/tests/fixtures/workflows/template-tests/template-array.psd1 @@ -7,8 +7,8 @@ Type = 'IdLE.Step.Test' With = @{ Emails = @( - '{{Request.Input.PrimaryEmail}}' - '{{Request.Input.SecondaryEmail}}' + '{{Request.Intent.PrimaryEmail}}' + '{{Request.Intent.SecondaryEmail}}' ) } } diff --git a/tests/fixtures/workflows/template-tests/template-boolean.psd1 b/tests/fixtures/workflows/template-tests/template-boolean.psd1 index e97ff909..76467092 100644 --- a/tests/fixtures/workflows/template-tests/template-boolean.psd1 +++ b/tests/fixtures/workflows/template-tests/template-boolean.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = 'Enabled: {{Request.Input.IsEnabled}}' + Value = 'Enabled: {{Request.Intent.IsEnabled}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-desiredstate.psd1 b/tests/fixtures/workflows/template-tests/template-desiredstate.psd1 index c7023998..7145a15f 100644 --- a/tests/fixtures/workflows/template-tests/template-desiredstate.psd1 +++ b/tests/fixtures/workflows/template-tests/template-desiredstate.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Department = '{{Request.DesiredState.Department}}' + Department = '{{Request.Intent.Department}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 b/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 index 9c183fbf..e61e3416 100644 --- a/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 +++ b/tests/fixtures/workflows/template-tests/template-escaped-mixed.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = 'Literal \{{ and template {{Request.Input.Name}}' + Value = 'Literal \{{ and template {{Request.Intent.Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-hashtable.psd1 b/tests/fixtures/workflows/template-tests/template-hashtable.psd1 index 63f86c99..fb5219aa 100644 --- a/tests/fixtures/workflows/template-tests/template-hashtable.psd1 +++ b/tests/fixtures/workflows/template-tests/template-hashtable.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.UserData}}' + Value = '{{Request.Intent.UserData}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-input-alias.psd1 b/tests/fixtures/workflows/template-tests/template-input-alias.psd1 index e7cce502..f6ac18fa 100644 --- a/tests/fixtures/workflows/template-tests/template-input-alias.psd1 +++ b/tests/fixtures/workflows/template-tests/template-input-alias.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.Name}}' + Value = '{{Request.Intent.Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-input-exists.psd1 b/tests/fixtures/workflows/template-tests/template-input-exists.psd1 index 14f6c2ea..c9cd0159 100644 --- a/tests/fixtures/workflows/template-tests/template-input-exists.psd1 +++ b/tests/fixtures/workflows/template-tests/template-input-exists.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.Name}}' + Value = '{{Request.Intent.Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-missing-path.psd1 b/tests/fixtures/workflows/template-tests/template-missing-path.psd1 index fde9f978..d9bc6788 100644 --- a/tests/fixtures/workflows/template-tests/template-missing-path.psd1 +++ b/tests/fixtures/workflows/template-tests/template-missing-path.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.NonExistent}}' + Value = '{{Request.Intent.NonExistent}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-mixed-boolean.psd1 b/tests/fixtures/workflows/template-tests/template-mixed-boolean.psd1 index 6dd771d9..21c57c37 100644 --- a/tests/fixtures/workflows/template-tests/template-mixed-boolean.psd1 +++ b/tests/fixtures/workflows/template-tests/template-mixed-boolean.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Message = 'Account enabled: {{Request.Input.Enabled}}' + Message = 'Account enabled: {{Request.Intent.Enabled}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-multiple.psd1 b/tests/fixtures/workflows/template-tests/template-multiple.psd1 index a517dbb3..652395d2 100644 --- a/tests/fixtures/workflows/template-tests/template-multiple.psd1 +++ b/tests/fixtures/workflows/template-tests/template-multiple.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Message = 'User {{Request.Input.DisplayName}} ({{Request.Input.UserPrincipalName}}) is joining.' + Message = 'User {{Request.Intent.DisplayName}} ({{Request.Intent.UserPrincipalName}}) is joining.' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-nested-hash.psd1 b/tests/fixtures/workflows/template-tests/template-nested-hash.psd1 index ebcd065d..b6c4de47 100644 --- a/tests/fixtures/workflows/template-tests/template-nested-hash.psd1 +++ b/tests/fixtures/workflows/template-tests/template-nested-hash.psd1 @@ -7,8 +7,8 @@ Type = 'IdLE.Step.Test' With = @{ User = @{ - Name = '{{Request.Input.DisplayName}}' - Email = '{{Request.Input.Mail}}' + Name = '{{Request.Intent.DisplayName}}' + Email = '{{Request.Intent.Mail}}' } } } diff --git a/tests/fixtures/workflows/template-tests/template-null-value.psd1 b/tests/fixtures/workflows/template-tests/template-null-value.psd1 index 0934e8c4..64813312 100644 --- a/tests/fixtures/workflows/template-tests/template-null-value.psd1 +++ b/tests/fixtures/workflows/template-tests/template-null-value.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.NullField}}' + Value = '{{Request.Intent.NullField}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-numeric.psd1 b/tests/fixtures/workflows/template-tests/template-numeric.psd1 index 8e764866..f323824b 100644 --- a/tests/fixtures/workflows/template-tests/template-numeric.psd1 +++ b/tests/fixtures/workflows/template-tests/template-numeric.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = 'ID: {{Request.Input.UserId}}' + Value = 'ID: {{Request.Intent.UserId}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-onfailure.psd1 b/tests/fixtures/workflows/template-tests/template-onfailure.psd1 index a19fe396..584d1088 100644 --- a/tests/fixtures/workflows/template-tests/template-onfailure.psd1 +++ b/tests/fixtures/workflows/template-tests/template-onfailure.psd1 @@ -6,7 +6,7 @@ Name = 'MainStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.Name}}' + Value = '{{Request.Intent.Name}}' } } ) @@ -15,7 +15,7 @@ Name = 'FailureHandler' Type = 'IdLE.Step.Test' With = @{ - ErrorMessage = 'Failed for user {{Request.Input.UserPrincipalName}}' + ErrorMessage = 'Failed for user {{Request.Intent.UserPrincipalName}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-path-spaces.psd1 b/tests/fixtures/workflows/template-tests/template-path-spaces.psd1 index 876498db..8e5c702c 100644 --- a/tests/fixtures/workflows/template-tests/template-path-spaces.psd1 +++ b/tests/fixtures/workflows/template-tests/template-path-spaces.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.User Name}}' + Value = '{{Request.Intent.User Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-path-special.psd1 b/tests/fixtures/workflows/template-tests/template-path-special.psd1 index a8998840..84fc0b4d 100644 --- a/tests/fixtures/workflows/template-tests/template-path-special.psd1 +++ b/tests/fixtures/workflows/template-tests/template-path-special.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.User@Name}}' + Value = '{{Request.Intent.User@Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-pure-boolean-false.psd1 b/tests/fixtures/workflows/template-tests/template-pure-boolean-false.psd1 index 5546d9a9..32e31193 100644 --- a/tests/fixtures/workflows/template-tests/template-pure-boolean-false.psd1 +++ b/tests/fixtures/workflows/template-tests/template-pure-boolean-false.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Enabled = '{{Request.Input.Enabled}}' + Enabled = '{{Request.Intent.Enabled}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-pure-boolean-true.psd1 b/tests/fixtures/workflows/template-tests/template-pure-boolean-true.psd1 index de4cf3a7..3d933d7c 100644 --- a/tests/fixtures/workflows/template-tests/template-pure-boolean-true.psd1 +++ b/tests/fixtures/workflows/template-tests/template-pure-boolean-true.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - IsActive = '{{Request.Input.IsActive}}' + IsActive = '{{Request.Intent.IsActive}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-pure-datetime.psd1 b/tests/fixtures/workflows/template-tests/template-pure-datetime.psd1 index 0d06345e..844a5b1d 100644 --- a/tests/fixtures/workflows/template-tests/template-pure-datetime.psd1 +++ b/tests/fixtures/workflows/template-tests/template-pure-datetime.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - StartDate = '{{Request.Input.StartDate}}' + StartDate = '{{Request.Intent.StartDate}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-pure-guid.psd1 b/tests/fixtures/workflows/template-tests/template-pure-guid.psd1 index f4c66540..3a2f4176 100644 --- a/tests/fixtures/workflows/template-tests/template-pure-guid.psd1 +++ b/tests/fixtures/workflows/template-tests/template-pure-guid.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - ObjectId = '{{Request.Input.ObjectId}}' + ObjectId = '{{Request.Intent.ObjectId}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-pure-integer.psd1 b/tests/fixtures/workflows/template-tests/template-pure-integer.psd1 index b9292f99..65705f6b 100644 --- a/tests/fixtures/workflows/template-tests/template-pure-integer.psd1 +++ b/tests/fixtures/workflows/template-tests/template-pure-integer.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - UserId = '{{Request.Input.UserId}}' + UserId = '{{Request.Intent.UserId}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-simple.psd1 b/tests/fixtures/workflows/template-tests/template-simple.psd1 index bba47ec9..7cdfbdf8 100644 --- a/tests/fixtures/workflows/template-tests/template-simple.psd1 +++ b/tests/fixtures/workflows/template-tests/template-simple.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - UserName = '{{Request.Input.UserPrincipalName}}' + UserName = '{{Request.Intent.UserPrincipalName}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 b/tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 index cbe8a751..f9f4ae40 100644 --- a/tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 +++ b/tests/fixtures/workflows/template-tests/template-unbalanced-close.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = 'Request.Input.Name}}' + Value = 'Request.Intent.Name}}' } } ) diff --git a/tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 b/tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 index 3b5d51f0..e6e3e6db 100644 --- a/tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 +++ b/tests/fixtures/workflows/template-tests/template-unbalanced-open.psd1 @@ -6,7 +6,7 @@ Name = 'TestStep' Type = 'IdLE.Step.Test' With = @{ - Value = '{{Request.Input.Name' + Value = '{{Request.Intent.Name' } } ) From eac3d7eaf0b2e70959687cfee3424a22c22b83d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:54:19 +0000 Subject: [PATCH 5/9] Improve examples, normalize param defaults, expand Context test coverage Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- .../Public/New-IdleRequestObject.ps1 | 8 +-- src/IdLE/Public/New-IdleRequest.ps1 | 8 ++- .../Resolve-IdleWorkflowTemplates.Tests.ps1 | 62 ++++++++++++++++++- .../template-context-missing-path.psd1 | 13 ++++ .../template-context-multiple.psd1 | 13 ++++ .../template-context-nested-hash.psd1 | 16 +++++ 6 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/workflows/template-tests/template-context-missing-path.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-context-multiple.psd1 create mode 100644 tests/fixtures/workflows/template-tests/template-context-nested-hash.psd1 diff --git a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 index 573759d0..aa948d07 100644 --- a/src/IdLE.Core/Public/New-IdleRequestObject.ps1 +++ b/src/IdLE.Core/Public/New-IdleRequestObject.ps1 @@ -90,19 +90,15 @@ function New-IdleRequestObject { [hashtable] $IdentityKeys = @{}, [Parameter()] - [hashtable] $Intent, + [hashtable] $Intent = @{}, [Parameter()] - [hashtable] $Context, + [hashtable] $Context = @{}, [Parameter()] [hashtable] $Changes ) - # Default to empty hashtables when not provided. - if ($null -eq $Intent) { $Intent = @{} } - if ($null -eq $Context) { $Context = @{} } - # Validate that no ScriptBlocks are present in the input data Assert-IdleNoScriptBlock -InputObject $IdentityKeys -Path 'IdentityKeys' Assert-IdleNoScriptBlock -InputObject $Intent -Path 'Intent' diff --git a/src/IdLE/Public/New-IdleRequest.ps1 b/src/IdLE/Public/New-IdleRequest.ps1 index 4aeeb035..693393e9 100644 --- a/src/IdLE/Public/New-IdleRequest.ps1 +++ b/src/IdLE/Public/New-IdleRequest.ps1 @@ -32,10 +32,12 @@ function New-IdleRequest { Optional hashtable describing changes (typically used for Mover lifecycle events). .EXAMPLE + # Minimal Joiner request — CorrelationId is auto-generated, Intent/Context default to empty New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } .EXAMPLE - New-IdleRequest -LifecycleEvent Joiner -Intent @{ Department = 'Engineering'; Title = 'Engineer' } + # Joiner request with caller-provided action inputs (Intent) and read-only associated context (Context) + New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } -Intent @{ Department = 'Engineering'; Title = 'Engineer' } -Context @{ Identity = @{ ObjectId = 'abc-123' } } .OUTPUTS IdleLifecycleRequest @@ -56,10 +58,10 @@ function New-IdleRequest { [hashtable] $IdentityKeys = @{}, [Parameter()] - [hashtable] $Intent, + [hashtable] $Intent = @{}, [Parameter()] - [hashtable] $Context, + [hashtable] $Context = @{}, [Parameter()] [hashtable] $Changes diff --git a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 index 257c2559..160ba5a5 100644 --- a/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 +++ b/tests/Core/Resolve-IdleWorkflowTemplates.Tests.ps1 @@ -95,7 +95,7 @@ Describe 'Template Substitution' { } Context 'Multiple placeholders in one string' { - It 'resolves multiple placeholders in a single string' { + It 'resolves multiple Intent placeholders in a single string' { $wfPath = Get-TemplateTestFixture 'template-multiple' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ @@ -113,10 +113,31 @@ Describe 'Template Substitution' { $plan.Steps[0].With.Message | Should -Be 'User John Doe (jdoe@example.com) is joining.' } + + It 'resolves multiple Context placeholders in a single string' { + $wfPath = Get-TemplateTestFixture 'template-context-multiple' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Context @{ + Identity = @{ + DisplayName = 'Jane Smith' + ObjectId = 'abc-123' + } + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Message | Should -Be 'Identity Jane Smith (abc-123) loaded.' + } } Context 'Nested hashtable and array substitution' { - It 'resolves templates in nested hashtables' { + It 'resolves Intent templates in nested hashtables' { $wfPath = Get-TemplateTestFixture 'template-nested-hash' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ @@ -136,6 +157,28 @@ Describe 'Template Substitution' { $plan.Steps[0].With.User.Email | Should -Be 'jsmith@example.com' } + It 'resolves Context templates in nested hashtables' { + $wfPath = Get-TemplateTestFixture 'template-context-nested-hash' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Context @{ + Identity = @{ + DisplayName = 'Alice Brown' + Mail = 'alice.brown@example.com' + } + } + $providers = @{ + StepRegistry = @{ + 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' + } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + $plan = New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers + + $plan.Steps[0].With.Identity.Name | Should -Be 'Alice Brown' + $plan.Steps[0].With.Identity.Email | Should -Be 'alice.brown@example.com' + } + It 'resolves templates in arrays' { $wfPath = Get-TemplateTestFixture 'template-array' @@ -214,7 +257,7 @@ Describe 'Template Substitution' { } Context 'Missing path segments' { - It 'throws when path does not exist' { + It 'throws when Intent path does not exist' { $wfPath = Get-TemplateTestFixture 'template-missing-path' $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Intent @{ Name = 'Test' } @@ -226,6 +269,19 @@ Describe 'Template Substitution' { { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | Should -Throw -ExpectedMessage '*resolved to null or does not exist*' } + + It 'throws when Context path does not exist' { + $wfPath = Get-TemplateTestFixture 'template-context-missing-path' + + $req = New-IdleTestRequest -LifecycleEvent 'Joiner' -Context @{ Identity = @{ ObjectId = 'abc' } } + $providers = @{ + StepRegistry = @{ 'IdLE.Step.Test' = 'Invoke-IdleTestNoopStep' } + StepMetadata = New-IdleTestStepMetadata -StepTypes @('IdLE.Step.Test') + } + + { New-IdlePlan -WorkflowPath $wfPath -Request $req -Providers $providers } | + Should -Throw -ExpectedMessage '*resolved to null or does not exist*' + } } Context 'Null resolved values' { diff --git a/tests/fixtures/workflows/template-tests/template-context-missing-path.psd1 b/tests/fixtures/workflows/template-tests/template-context-missing-path.psd1 new file mode 100644 index 00000000..e370e1d0 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-context-missing-path.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Context Missing Path' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Value = '{{Request.Context.NonExistent}}' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-context-multiple.psd1 b/tests/fixtures/workflows/template-tests/template-context-multiple.psd1 new file mode 100644 index 00000000..a7d6a241 --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-context-multiple.psd1 @@ -0,0 +1,13 @@ +@{ + Name = 'Template Test - Context Multiple' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Message = 'Identity {{Request.Context.Identity.DisplayName}} ({{Request.Context.Identity.ObjectId}}) loaded.' + } + } + ) +} diff --git a/tests/fixtures/workflows/template-tests/template-context-nested-hash.psd1 b/tests/fixtures/workflows/template-tests/template-context-nested-hash.psd1 new file mode 100644 index 00000000..db2c4dfd --- /dev/null +++ b/tests/fixtures/workflows/template-tests/template-context-nested-hash.psd1 @@ -0,0 +1,16 @@ +@{ + Name = 'Template Test - Context Nested Hash' + LifecycleEvent = 'Joiner' + Steps = @( + @{ + Name = 'TestStep' + Type = 'IdLE.Step.Test' + With = @{ + Identity = @{ + Name = '{{Request.Context.Identity.DisplayName}}' + Email = '{{Request.Context.Identity.Mail}}' + } + } + } + ) +} From f89dd81c0e5cb7407b9d1469df199845c49b5ee5 Mon Sep 17 00:00:00 2001 From: Matthias <13959569+blindzero@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:07:56 +0100 Subject: [PATCH 6/9] Update docs/reference/cmdlets/New-IdleRequest.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/reference/cmdlets/New-IdleRequest.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/cmdlets/New-IdleRequest.md b/docs/reference/cmdlets/New-IdleRequest.md index e6f73560..512be81e 100644 --- a/docs/reference/cmdlets/New-IdleRequest.md +++ b/docs/reference/cmdlets/New-IdleRequest.md @@ -134,7 +134,7 @@ Aliases: Required: False Position: 6 -Default value: None +Default value: @{} Accept pipeline input: False Accept wildcard characters: False ``` From 3ba492d35dc1e9edc40e3a6a49cd94c3f4c3b4bc Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:12:30 +0100 Subject: [PATCH 7/9] chore: adding temp to gitignore --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index a1bcdc1c..8e1bd534 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,11 @@ pids *.seed *.pid.lock +# Temp directories +# Temp files +tmp/ +temp/ +*.tmp +*.temp + # End of file \ No newline at end of file From db10559bab57230adc2c4640c0168ececf9515aa Mon Sep 17 00:00:00 2001 From: Matthias Fleschuetz <13959569+blindzero@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:12:39 +0100 Subject: [PATCH 8/9] docs: update cmdlet reference --- docs/reference/cmdlets/New-IdleRequest.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/reference/cmdlets/New-IdleRequest.md b/docs/reference/cmdlets/New-IdleRequest.md index 512be81e..69e111af 100644 --- a/docs/reference/cmdlets/New-IdleRequest.md +++ b/docs/reference/cmdlets/New-IdleRequest.md @@ -13,10 +13,9 @@ Creates a lifecycle request object. ## SYNTAX ``` -New-IdleRequest [-LifecycleEvent] [[-CorrelationId] ] [[-Actor] ] - [[-IdentityKeys] ] [[-Intent] ] [[-Context] ] - [[-Changes] ] - [-ProgressAction ] [] +New-IdleRequest [-LifecycleEvent] <String> [[-CorrelationId] <String>] [[-Actor] <String>] + [[-IdentityKeys] <Hashtable>] [[-Intent] <Hashtable>] [[-Context] <Hashtable>] [[-Changes] <Hashtable>] + [-ProgressAction <ActionPreference>] [<CommonParameters>] ``` ## DESCRIPTION @@ -31,12 +30,14 @@ Changes is optional and stays $null when omitted. ### EXAMPLE 1 ``` +# Minimal Joiner request - CorrelationId is auto-generated, Intent/Context default to empty New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } ``` ### EXAMPLE 2 ``` -New-IdleRequest -LifecycleEvent Joiner -Intent @{ Department = 'Engineering'; Title = 'Engineer' } +# Joiner request with caller-provided action inputs (Intent) and read-only associated context (Context) +New-IdleRequest -LifecycleEvent Joiner -CorrelationId (New-Guid) -IdentityKeys @{ EmployeeId = '12345' } -Intent @{ Department = 'Engineering'; Title = 'Engineer' } -Context @{ Identity = @{ ObjectId = 'abc-123' } } ``` ## PARAMETERS @@ -116,7 +117,7 @@ Aliases: Required: False Position: 5 -Default value: None +Default value: @{} Accept pipeline input: False Accept wildcard characters: False ``` From 778367792019a0198437dd8d066069634a0225c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:12:50 +0000 Subject: [PATCH 9/9] Include Request.Context in plan export (fix silent drop regression) Co-authored-by: blindzero <13959569+blindzero@users.noreply.github.com> --- src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 | 6 ++++-- tests/fixtures/plan-export/expected/plan-export.json | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 index ba4a7834..7226a225 100644 --- a/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 +++ b/src/IdLE.Core/Private/ConvertTo-IdlePlanExportObject.ps1 @@ -80,16 +80,18 @@ function ConvertTo-IdlePlanExportObject { $requestInput = Get-FirstPropertyValue -Object $request -Names @('Input', 'Data', 'Payload', 'Attributes') if ($null -eq $requestInput) { - # IdLE lifecycle requests store business intent as IdentityKeys/Intent/Changes. + # IdLE lifecycle requests store business intent as IdentityKeys/Intent/Context/Changes. # When present, export these as the canonical request.input payload. $identityKeys = Get-FirstPropertyValue -Object $request -Names @('IdentityKeys', 'IdentityKey', 'Keys') $intent = Get-FirstPropertyValue -Object $request -Names @('Intent', 'TargetState') + $context = Get-FirstPropertyValue -Object $request -Names @('Context') $changes = Get-FirstPropertyValue -Object $request -Names @('Changes', 'Delta') - if ($null -ne $identityKeys -or $null -ne $intent -or $null -ne $changes) { + if ($null -ne $identityKeys -or $null -ne $intent -or $null -ne $context -or $null -ne $changes) { $requestInput = New-OrderedMap $requestInput.identityKeys = $identityKeys $requestInput.intent = $intent + $requestInput.context = $context $requestInput.changes = $changes } } diff --git a/tests/fixtures/plan-export/expected/plan-export.json b/tests/fixtures/plan-export/expected/plan-export.json index 348a2ad7..1a4bea62 100644 --- a/tests/fixtures/plan-export/expected/plan-export.json +++ b/tests/fixtures/plan-export/expected/plan-export.json @@ -14,6 +14,7 @@ "intent": { "department": "IT" }, + "context": {}, "changes": null } },