Private/ConvertTo-IdlePlanExportObject.ps1
|
<# .SYNOPSIS Maps an internal LifecyclePlan object to the canonical Plan Export contract DTO. .DESCRIPTION This function is the single source of truth for the Plan Export JSON contract mapping. It produces a pure data object (ordered hashtables) that can be serialized to JSON deterministically. Contract stability decisions: - engine.version is intentionally omitted (avoid noise on module version bumps) - plan.createdAt is intentionally omitted (avoid non-deterministic timestamps in exports) - empty strings are normalized to $null for identifier-like fields (e.g., actor) The mapping is defensive and supports multiple internal property names to reduce coupling to internal refactors. #> function ConvertTo-IdlePlanExportObject { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNull()] [object] $Plan ) function New-OrderedMap { [CmdletBinding()] param() return [ordered] @{} } function Get-FirstPropertyValue { [CmdletBinding()] param( [Parameter(Mandatory)] [object] $Object, [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]] $Names ) foreach ($name in $Names) { if ($Object -is [System.Collections.IDictionary]) { if ($Object.Contains($name)) { return $Object[$name] } continue } $prop = $Object.PSObject.Properties[$name] if ($null -ne $prop) { return $prop.Value } } return $null } # Maximum UTF-8 byte count for a single snapshot field (identityKeys, intent, context). # Fields serializing beyond this limit are replaced with a deterministic truncation marker # to prevent unbounded snapshot artifacts. 64 KB is a conservative bound per field. $snapshotFieldSizeLimit = 65536 function Limit-IdleSnapshotField { # Returns the value unchanged if its serialized UTF-8 size is within $snapshotFieldSizeLimit. # Returns a '[TRUNCATED - N bytes]' marker string when the limit is exceeded. [CmdletBinding()] param( [Parameter()] [AllowNull()] [object] $Value ) if ($null -eq $Value) { return $null } $serialized = $Value | ConvertTo-Json -Depth 20 -Compress $byteCount = [System.Text.Encoding]::UTF8.GetByteCount($serialized) if ($byteCount -gt $snapshotFieldSizeLimit) { return "[TRUNCATED - $byteCount bytes]" } return $Value } # ---- Engine block -------------------------------------------------------- $engineMap = New-OrderedMap $engineMap.name = 'IdLE' # ---- Request block ------------------------------------------------------- # Prefer an explicit request object if present. Otherwise, fall back to plan fields. $request = Get-FirstPropertyValue -Object $Plan -Names @('Request', 'LifecycleRequest', 'InputRequest') $requestType = $null $correlationId = $null $actor = $null $requestInput = $null if ($null -ne $request) { $requestType = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $request -Names @('Type', 'RequestType', 'LifecycleType', 'Kind', 'LifecycleEvent') ) $correlationId = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $request -Names @('CorrelationId', 'CorrelationID', 'Correlation', 'Id') ) $actor = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $request -Names @('Actor', 'RequestedBy', 'Source', 'Origin') ) # Keep input opaque. We do not transform or validate here. $requestInput = Get-FirstPropertyValue -Object $request -Names @('Input', 'Data', 'Payload', 'Attributes') if ($null -eq $requestInput) { # IdLE lifecycle requests store business intent as IdentityKeys/Intent/Context. # 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') if ($null -ne $identityKeys -or $null -ne $intent -or $null -ne $context) { $requestInput = New-OrderedMap $requestInput.identityKeys = $identityKeys $requestInput.intent = $intent $requestInput.context = $context } } } else { # Plan-shaped fallback (current IdLE plan object shape). $requestType = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $Plan -Names @('LifecycleEvent', 'Type', 'RequestType') ) $correlationId = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $Plan -Names @('CorrelationId', 'CorrelationID', 'Id', 'PlanId', 'PlanID') ) $actor = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $Plan -Names @('Actor', 'RequestedBy') ) $requestInput = $null } # Redact request.input at the export boundary (do not mutate the original request object). $redactedRequestInput = if ($null -ne $requestInput) { Copy-IdleRedactedObject -Value $requestInput } else { $null } # Enforce per-field size limits on the redacted snapshot fields. # identityKeys, intent and context are each bounded to $snapshotFieldSizeLimit bytes (serialized UTF-8). # Fields that exceed the limit are replaced with a deterministic truncation marker. # Handle both IDictionary (hashtable / ordered) and PSCustomObject shapes. if ($null -ne $redactedRequestInput) { foreach ($fieldName in @('identityKeys', 'intent', 'context')) { if ($redactedRequestInput -is [System.Collections.IDictionary]) { if ($redactedRequestInput.Contains($fieldName)) { $redactedRequestInput[$fieldName] = Limit-IdleSnapshotField -Value $redactedRequestInput[$fieldName] } } else { $prop = $redactedRequestInput.PSObject.Properties[$fieldName] if ($null -ne $prop) { $prop.Value = Limit-IdleSnapshotField -Value $prop.Value } } } } $requestMap = New-OrderedMap $requestMap.type = $requestType $requestMap.correlationId = $correlationId $requestMap.actor = $actor $requestMap.input = $redactedRequestInput # ---- Plan block ---------------------------------------------------------- $planId = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $Plan -Names @('Id', 'PlanId', 'PlanID', 'CorrelationId', 'CorrelationID') ) $mode = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $Plan -Names @('Mode', 'State', 'Status') ) # plan.createdAt is intentionally omitted (non-deterministic in current implementation) $steps = Get-FirstPropertyValue -Object $Plan -Names @('Steps', 'Items', 'PlanSteps', 'Entries') if ($null -eq $steps) { $steps = @() } $stepList = @() $index = 0 foreach ($step in $steps) { $index++ if ($null -eq $step) { continue } $stepId = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $step -Names @('Id', 'StepId', 'StepID') ) if ([string]::IsNullOrWhiteSpace([string] $stepId)) { # Deterministic fallback id when none exists. $stepId = ('step-{0:00}' -f $index) } $stepName = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $step -Names @('Name', 'DisplayName', 'Title') ) $stepType = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $step -Names @('StepType', 'Type', 'Kind') ) $provider = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $step -Names @('Provider', 'ProviderName', 'Adapter', 'Target') ) # Conditions are exported declaratively without evaluation. $condition = Get-FirstPropertyValue -Object $step -Names @('Condition', 'When', 'Applicability', 'Guard') if ($null -ne $condition) { $conditionType = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $condition -Names @('Type', 'Kind') ) $expression = ConvertTo-NullIfEmptyString -Value ( Get-FirstPropertyValue -Object $condition -Names @('Expression', 'Expr', 'Query') ) $conditionMap = New-OrderedMap $conditionMap.type = $conditionType $conditionMap.expression = $expression } else { $conditionMap = New-OrderedMap $conditionMap.type = 'always' $conditionMap.expression = $null } # Inputs and expectedState are treated as opaque, pure data. # Current IdLE plan object shape uses 'With' for inputs. $inputs = Get-FirstPropertyValue -Object $step -Names @('Inputs', 'Input', 'Parameters', 'Arguments', 'With') $expectedState = Get-FirstPropertyValue -Object $step -Names @('ExpectedState', 'DesiredState', 'TargetState') # Redact step inputs / expectedState at the export boundary (do not mutate original step objects). $redactedInputs = if ($null -ne $inputs) { Copy-IdleRedactedObject -Value $inputs } else { $null } $redactedExpectedState = if ($null -ne $expectedState) { Copy-IdleRedactedObject -Value $expectedState } else { $null } $stepMap = New-OrderedMap $stepMap.id = $stepId $stepMap.name = $stepName $stepMap.stepType = $stepType $stepMap.provider = $provider $stepMap.condition = $conditionMap $stepMap.inputs = $redactedInputs $stepMap.expectedState = $redactedExpectedState # Per-step planning warnings (e.g. unresolved precondition context paths). $rawStepWarnings = Get-FirstPropertyValue -Object $step -Names @('Warnings', 'PlanningWarnings', 'StepWarnings') $stepWarningList = @() foreach ($sw in @($rawStepWarnings)) { if ($null -eq $sw) { continue } $stepWarningMap = New-OrderedMap $stepWarningMap.code = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Code', 'code')) $stepWarningMap.type = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Type', 'type')) $stepWarningMap.source = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Source', 'source')) $stepWarningMap.paths = Get-FirstPropertyValue -Object $sw -Names @('Paths', 'paths') $stepWarningMap.message = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $sw -Names @('Message', 'message')) $stepWarningList += $stepWarningMap } $stepMap.warnings = $stepWarningList $stepList += $stepMap } # ---- Plan warnings ------------------------------------------------------ $rawWarnings = Get-FirstPropertyValue -Object $Plan -Names @('Warnings', 'PlanningWarnings') $warningList = @() foreach ($w in @($rawWarnings)) { if ($null -eq $w) { continue } $warningMap = New-OrderedMap $warningMap.code = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Code', 'code')) $warningMap.type = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Type', 'type')) $warningMap.step = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Step', 'step', 'StepName')) $warningMap.source = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Source', 'source')) $warningMap.paths = Get-FirstPropertyValue -Object $w -Names @('Paths', 'paths') $warningMap.message = ConvertTo-NullIfEmptyString -Value (Get-FirstPropertyValue -Object $w -Names @('Message', 'message')) $warningList += $warningMap } $planMap = New-OrderedMap $planMap.id = $planId $planMap.mode = $mode $planMap.steps = $stepList $planMap.warnings = $warningList # ---- Metadata block ------------------------------------------------------ $metadataMap = New-OrderedMap $metadataMap.generatedBy = 'Export-IdlePlanObject' $metadataMap.environment = $null $metadataMap.labels = @() # ---- Root --------------------------------------------------------------- $root = New-OrderedMap $root.schemaVersion = '1.0' $root.engine = $engineMap $root.request = $requestMap $root.plan = $planMap $root.metadata = $metadataMap return $root } |