Public/SyncRuleMappings/New-JIMSyncRuleMapping.ps1

# Copyright (c) Tetron Limited. All rights reserved.
# Licensed under the Tetron Commercial License. See LICENSE file in the project root.

function New-JIMSyncRuleMapping {
    <#
    .SYNOPSIS
        Adds an Attribute Flow to a Synchronisation Rule.
 
    .DESCRIPTION
        Adds an Attribute Flow (mapping) to a Synchronisation Rule. On an import rule it writes a Metaverse
        attribute from Connected System attributes or an expression; on an export rule it writes a Connected System
        attribute from Metaverse attributes or an expression. With -Generate, its value is a generated value: a base
        expression plus a uniqueness token.
 
    .PARAMETER SyncRuleId
        The ID of the Synchronisation Rule to add the mapping to.
 
    .PARAMETER TargetMetaverseAttributeId
        On an import rule, the ID of the Metaverse attribute the mapping writes.
 
    .PARAMETER TargetConnectedSystemAttributeId
        On an export rule, the ID of the Connected System attribute the mapping writes.
 
    .PARAMETER SourceConnectedSystemAttributeId
        On an import rule, the IDs of the Connected System attributes the mapping reads. Cannot be used with
        -Expression.
 
    .PARAMETER SourceMetaverseAttributeId
        On an export rule, the IDs of the Metaverse attributes the mapping reads. Cannot be used with -Expression.
 
    .PARAMETER MissingInputBehaviour
        On an expression mapping, what happens when an attribute the expression reads has no value: EvaluateAnyway
        runs it regardless and contributes what it returns; ContributeNoValue skips it and contributes nothing, so
        Attribute Priority decides; FailMapping skips it and records an error, while the object's other attributes
        still flow; and FailObject leaves the whole object untouched and errored. EvaluateAnyway when omitted.
 
    .PARAMETER Expression
        The expression the mapping evaluates, reading attributes as mv["Name"] and cs["Name"]: for example '"CN=" +
        EscapeDN(mv["Display Name"]) + ",OU=Users,DC=domain,DC=local"'. With -Generate, the base value the token is
        added to.
 
    .PARAMETER PreserveWhitespace
        On an import mapping, keeps a text value that is empty or only whitespace as a value. Without it, such a
        value counts as no value: it does not flow, and it clears the Metaverse attribute.
 
    .PARAMETER TrimWhitespace
        On an import mapping, removes leading and trailing whitespace from a text value.
 
    .PARAMETER CollapseInternalWhitespace
        On an import mapping, collapses each run of whitespace inside a text value to one space.
 
    .PARAMETER CaseNormalisation
        On an import mapping, the case a text value is converted to.
 
    .PARAMETER NullIsValue
        On an import mapping, makes no value authoritative: when the rule applies to a joined, in-scope Connected
        System Object that has no value, the Metaverse attribute is cleared rather than taken from a lower-priority
        mapping. Change it later with Set-JIMMetaverseAttributePriority or Move-JIMMetaverseAttributePriority.
 
    .PARAMETER InitialExportOnly
        On an export mapping, writes the attribute only when the Connected System Object is created; after that JIM
        leaves it alone, and Drift Correction does not put it back.
 
    .PARAMETER Enabled
        Whether the mapping takes part in synchronisation from the start. $false creates it disabled, so it can be
        put in order and reviewed first; enable it later with Set-JIMSyncRuleMapping.
 
    .PARAMETER KeepUnique
        On an Expression mapping whose value is built from a generated value (an Email built from a generated
        Account Name), whether a target refusing the value as already in use is fixed by correcting the generated
        value. $false reports the refusal as an ordinary export error instead. Not accepted on a generated mapping.
 
    .PARAMETER Generate
        Makes the mapping a generated value: a base value from -Expression, which a Sequence or Random token can do
        without, plus a uniqueness token. Give -TargetMetaverseAttributeId for an import mapping, or
        -TargetConnectedSystemAttributeId for an export one.
 
    .PARAMETER TokenKind
        The uniqueness token: OnlyIfTaken tries the base value first and adds a suffix only when it is taken, and
        needs -Expression; Sequence adds the next number from the attribute's counter, never reused; Random adds a
        random token (see -RandomFormat). OnlyIfTaken when omitted.
 
    .PARAMETER SuffixStyle
        OnlyIfTaken only: whether the collision suffix is a Number or a Letter. Omit for Number.
 
    .PARAMETER SuffixStart
        OnlyIfTaken only: the first suffix value tried once the bare base value is taken. Omit for 1.
 
    .PARAMETER SequenceStart
        Sequence only: the lowest number this flow will ever issue. If this stands above the target
        attribute's counter, the save moves the counter forward to it and the result's
        Generation.SequenceSkippedAhead reports the move; a warning is written naming the old and new
        positions. Omit for 1.
 
    .PARAMETER SequenceIncrement
        Sequence only: how much the counter advances per issued number. Omit for 1.
 
    .PARAMETER FixedWidth
        Sequence only: zero-pads the number to this many digits. Omit for no padding.
 
    .PARAMETER OnWidthExceeded
        Sequence only, and only meaningful with -FixedWidth: what happens when a number would no longer fit.
        StopAndReport (the default) stops the object with an attributed error; AllowLonger lets the number
        grow past the configured width.
 
    .PARAMETER RandomFormat
        Random only: the shape of the token. Guid (the default, a 36-character GUID), Hex (lower-case
        hexadecimal) or Digits (decimal digits). -RandomLength is required for Hex and Digits.
 
    .PARAMETER RandomLength
        Random only: the length of the token in characters. Required for -RandomFormat Hex or Digits; must be
        omitted for Guid, whose length is fixed.
 
    .PARAMETER Separator
        The characters placed between the base value and the token, when both are present. Omit for no
        separator. Never valid for a Number target.
 
    .PARAMETER AttemptLimit
        How many candidates are tried for one object in one synchronisation run before generation fails for it. 1000
        when omitted.
 
    .PARAMETER NeverReuse
        Whether a value whose assignment is deleted is retired and never issued again by this flow. Omit for
        $true (the server default and recommended setting). Always treated as true for a Sequence token,
        regardless of what is supplied, because its forward-only counter makes reuse impossible.
 
    .PARAMETER CollisionRemediation
        Whether JIM corrects a generated value a target rejects as already in use (Collision Remediation). While no
        other Connected System holds the value, JIM chooses the next free one and exports it again; once another
        does, the export is held for a decision instead (see Get-JIMGeneratedValueDecision). Acts only on a
        rejection from a Connected System whose Connector reports collisions (Generation.Participants'
        ReportsCollisions); elsewhere a collision is an ordinary export error. Omit for $true.
 
    .PARAMETER ExcludeConnectedSystemId
        Import generated mappings only: the IDs of Connected Systems to leave out of the value's availability
        checks. Values already in use in an excluded Connected System do not stop JIM choosing them; JIM neither
        checks its own records of that system nor probes it. Each must be a Connected System the value is
        exported to unchanged (an export Attribute Flow taking the generated Metaverse attribute as its only
        source); otherwise the request is refused naming the Connected System. Omit to check every Connected
        System the value is exported to. Get-JIMSyncRuleMapping's Generation.Participants lists them.
 
    .OUTPUTS
        The mapping created. Any warnings the save raised are written to the warning stream as well.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 1 -TargetMetaverseAttributeId 5 -SourceConnectedSystemAttributeId 10
 
        Creates an import mapping that flows Connected System attribute 10 into Metaverse attribute 5.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 2 -TargetConnectedSystemAttributeId 15 -SourceMetaverseAttributeId 8
 
        Creates an export mapping that flows Metaverse attribute 8 out to Connected System attribute 15.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 2 -TargetConnectedSystemAttributeId 15 -Expression '"CN=" + EscapeDN(mv["Display Name"]) + ",OU=TestUsers,DC=domain,DC=local"'
 
        Creates an export mapping that uses an expression to construct a Distinguished Name.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 2 -TargetConnectedSystemAttributeId 15 -Expression '"CN=" + EscapeDN(mv["Display Name"]) + ",OU=TestUsers,DC=domain,DC=local"' -MissingInputBehaviour FailObject
 
        Builds a Distinguished Name, and refuses to export an object with no Display Name rather than exporting
        "CN=,OU=TestUsers,DC=domain,DC=local".
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 1 -TargetMetaverseAttributeId 5 -Expression 'Lower(cs["FirstName"]) + "." + Lower(cs["LastName"]) + "@company.com"'
 
        Creates an import mapping that uses an expression to construct an email address.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 1 -TargetMetaverseAttributeId 5 -SourceConnectedSystemAttributeId 10 -TrimWhitespace -CaseNormalisation Lower
 
        Creates an import mapping that trims surrounding whitespace and lower-cases the value (and, by default,
        treats whitespace-only values as no value).
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 1 -TargetMetaverseAttributeId 5 -SourceConnectedSystemAttributeId 10 -NullIsValue
 
        Creates an import mapping that asserts no value when the source is connected and in scope but supplies
        nothing, clearing the Metaverse Object attribute value instead of falling through to a lower-priority
        contributor. The mapping still lands at the bottom of the attribute's priority list; promote it with
        Move-JIMMetaverseAttributePriority before it can win resolution.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 2 -TargetConnectedSystemAttributeId 15 -SourceMetaverseAttributeId 8 -InitialExportOnly
 
        Creates an export mapping that only flows during initial provisioning; the attribute is unmanaged afterwards.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 1 -TargetMetaverseAttributeId 5 -SourceConnectedSystemAttributeId 10 -Enabled $false
 
        Creates the mapping disabled, so it can be ordered and reviewed before it starts flowing values.
        Enable it when ready with Set-JIMSyncRuleMapping -Enabled $true.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 1 -TargetMetaverseAttributeId 5 `
            -Expression 'Lower(cs["FirstName"]) + "." + Lower(cs["LastName"])' -Generate
 
        Creates a generated import mapping: try "first.last" bare, and only if it is already taken append a
        collision suffix (OnlyIfTaken, the default token kind).
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 1 -TargetMetaverseAttributeId 12 -Generate -TokenKind Sequence -SequenceStart 100000 -FixedWidth 6
 
        Creates a generated Employee Number mapping with no base expression: a bare, zero-padded, six-digit
        Sequence starting at 100000.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 2 -TargetConnectedSystemAttributeId 40 -Generate -TokenKind Random -RandomFormat Hex -RandomLength 12
 
        Creates a generated export mapping whose value is a twelve-character random hexadecimal token, with no
        base expression.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 1 -TargetMetaverseAttributeId 5 `
            -Expression 'Lower(cs["FirstName"]) + "." + Lower(cs["LastName"])' -Generate -ExcludeConnectedSystemId 4
 
        Creates a generated Account Name that is not checked for availability in Connected System 4, for example
        a system known to keep its own accounts unique some other way.
 
    .LINK
        Get-JIMSyncRuleMapping
        Remove-JIMSyncRuleMapping
        Get-JIMSyncRule
        Get-JIMGeneratedValueSequence
        Restart-JIMGeneratedValues
        Test-JIMExpression
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType('JIM.SyncRuleMappingDto')]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ImportAttribute')]
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ImportExpression')]
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ExportAttribute')]
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ExportExpression')]
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ImportGenerated')]
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ExportGenerated')]
        [Alias('Id')]
        [int]$SyncRuleId,

        [Parameter(ParameterSetName = 'ImportAttribute')]
        [Parameter(ParameterSetName = 'ImportExpression')]
        [Parameter(ParameterSetName = 'ImportGenerated')]
        [int]$TargetMetaverseAttributeId,

        [Parameter(ParameterSetName = 'ExportAttribute')]
        [Parameter(ParameterSetName = 'ExportExpression')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [int]$TargetConnectedSystemAttributeId,

        [Parameter(ParameterSetName = 'ImportAttribute')]
        [int[]]$SourceConnectedSystemAttributeId,

        [Parameter(ParameterSetName = 'ExportAttribute')]
        [int[]]$SourceMetaverseAttributeId,

        # The base expression. Mandatory for an ordinary Expression mapping; optional for a generated mapping
        # (Unique Value Generation, #242), whose Sequence and Random token kinds can stand with no base value.
        [Parameter(ParameterSetName = 'ImportExpression')]
        [Parameter(ParameterSetName = 'ExportExpression')]
        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [string]$Expression,

        # What the Expression does when an attribute it reads has no value on the object being synchronised.
        # Omit for EvaluateAnyway, which is what JIM has always done.
        [Parameter(ParameterSetName = 'ImportExpression')]
        [Parameter(ParameterSetName = 'ExportExpression')]
        [ValidateSet('EvaluateAnyway', 'ContributeNoValue', 'FailMapping', 'FailObject')]
        [string]$MissingInputBehaviour,

        # "Generated Value" (Unique Value Generation, #242), import and export mappings alike: the mapping's
        # value is its (optional) base expression plus a uniqueness token, rather than a plain attribute or
        # Expression mapping.
        [Parameter(Mandatory, ParameterSetName = 'ImportGenerated')]
        [Parameter(Mandatory, ParameterSetName = 'ExportGenerated')]
        [switch]$Generate,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [ValidateSet('OnlyIfTaken', 'Sequence', 'Random')]
        [string]$TokenKind,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [ValidateSet('Number', 'Letter')]
        [string]$SuffixStyle,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [int]$SuffixStart,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [long]$SequenceStart,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [int]$SequenceIncrement,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [int]$FixedWidth,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [ValidateSet('StopAndReport', 'AllowLonger')]
        [string]$OnWidthExceeded,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [ValidateSet('Guid', 'Hex', 'Digits')]
        [string]$RandomFormat,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [int]$RandomLength,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [string]$Separator,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [int]$AttemptLimit,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [bool]$NeverReuse,

        [Parameter(ParameterSetName = 'ImportGenerated')]
        [Parameter(ParameterSetName = 'ExportGenerated')]
        [bool]$CollisionRemediation,

        # Exclusions (release 3), import generated mappings only: an export-mode generated value is checked
        # only in its own Connected System, so it has nothing to exclude.
        [Parameter(ParameterSetName = 'ImportGenerated')]
        [int[]]$ExcludeConnectedSystemId,

        # Inbound value processing (import mappings only). Whitespace-only/empty text values are treated as
        # no value by default; use -PreserveWhitespace to keep them as literal values instead.
        [Parameter(ParameterSetName = 'ImportAttribute')]
        [Parameter(ParameterSetName = 'ImportExpression')]
        [switch]$PreserveWhitespace,

        [Parameter(ParameterSetName = 'ImportAttribute')]
        [Parameter(ParameterSetName = 'ImportExpression')]
        [switch]$TrimWhitespace,

        [Parameter(ParameterSetName = 'ImportAttribute')]
        [Parameter(ParameterSetName = 'ImportExpression')]
        [switch]$CollapseInternalWhitespace,

        [Parameter(ParameterSetName = 'ImportAttribute')]
        [Parameter(ParameterSetName = 'ImportExpression')]
        [ValidateSet('None', 'Upper', 'Lower', 'Title')]
        [string]$CaseNormalisation = 'None',

        # Attribute Priority (#91), import mappings only: treat "connected, in scope, but no value" as an
        # authoritative clear rather than falling through to the next contributor.
        [Parameter(ParameterSetName = 'ImportAttribute')]
        [Parameter(ParameterSetName = 'ImportExpression')]
        [switch]$NullIsValue,

        # Initial Export Only (#223), export mappings only: the mapping flows solely during the initial
        # provisioning (Create) export; the attribute is unmanaged by JIM afterwards.
        [Parameter(ParameterSetName = 'ExportAttribute')]
        [Parameter(ParameterSetName = 'ExportExpression')]
        [switch]$InitialExportOnly,

        # Create disabled (#1485), import and export mappings alike: omit to create the mapping enabled (the
        # server default); supply $false to create it disabled for ordering and review before it flows values.
        [Parameter()]
        [bool]$Enabled,

        # Keep unique (Generated Value Forms, #2023), Expression mappings only: omit to keep the server default
        # (on); supply $false so a target's refusal of a value built from a generated value is an ordinary error.
        [Parameter(ParameterSetName = 'ImportExpression')]
        [Parameter(ParameterSetName = 'ExportExpression')]
        [bool]$KeepUnique
    )

    process {
        # Check connection first
        if (-not $script:JIMConnection) {
            Write-Error "You are not connected to JIM. Run Connect-JIM -Url <your JIM URL> to authenticate, then try again."
            return
        }

        # Determine direction and validate parameters
        $isImport = $PSBoundParameters.ContainsKey('TargetMetaverseAttributeId')
        $isExport = $PSBoundParameters.ContainsKey('TargetConnectedSystemAttributeId')
        $hasExpression = $PSBoundParameters.ContainsKey('Expression') -and -not [string]::IsNullOrWhiteSpace($Expression)
        $isGenerated = $PSBoundParameters.ContainsKey('Generate')

        if (-not $isImport -and -not $isExport) {
            Write-Error "You must specify either -TargetMetaverseAttributeId (for import) or -TargetConnectedSystemAttributeId (for export)."
            return
        }

        # Build request body
        $body = @{
            sources = @()
        }

        if ($isImport) {
            $body.targetMetaverseAttributeId = $TargetMetaverseAttributeId

            if ($hasExpression) {
                # Expression-based import mapping
                $expressionSource = @{
                    order = 0
                    expression = $Expression
                }
                if ($PSBoundParameters.ContainsKey('MissingInputBehaviour')) {
                    # Sent as the enum member name; the API rejects numeric ordinals.
                    $expressionSource.missingInputBehaviour = $MissingInputBehaviour
                }
                $body.sources += $expressionSource
            }
            elseif ($SourceConnectedSystemAttributeId) {
                # Attribute-based import mapping
                $order = 0
                foreach ($sourceId in $SourceConnectedSystemAttributeId) {
                    $body.sources += @{
                        order = $order
                        connectedSystemAttributeId = $sourceId
                    }
                    $order++
                }
            }
            elseif ($isGenerated) {
                # A generated mapping's base expression is optional: Sequence and Random token kinds stand
                # with no base value at all (the server validates this against -TokenKind).
            }
            else {
                Write-Error "-SourceConnectedSystemAttributeId or -Expression is required for import mappings."
                return
            }

            # Inbound value processing (#843), import mappings only. The flags enum is sent as a
            # comma-separated set of names; whitespace is treated as no value unless -PreserveWhitespace.
            $processingFlags = @()
            if (-not $PreserveWhitespace) { $processingFlags += 'TreatWhitespaceAsNoValue' }
            if ($TrimWhitespace) { $processingFlags += 'TrimWhitespace' }
            if ($CollapseInternalWhitespace) { $processingFlags += 'CollapseInternalWhitespace' }
            $body.inboundValueProcessing = if ($processingFlags.Count -gt 0) { $processingFlags -join ', ' } else { 'None' }
            $body.caseNormalisation = $CaseNormalisation

            # Attribute Priority (#91). Sent only when asked for, so the server's default of false stands otherwise.
            if ($NullIsValue) { $body.nullIsValue = $true }

            $targetDescription = "MV Attribute $TargetMetaverseAttributeId"
        }
        else {
            $body.targetConnectedSystemAttributeId = $TargetConnectedSystemAttributeId

            if ($hasExpression) {
                # Expression-based export mapping
                $expressionSource = @{
                    order = 0
                    expression = $Expression
                }
                if ($PSBoundParameters.ContainsKey('MissingInputBehaviour')) {
                    # Sent as the enum member name; the API rejects numeric ordinals.
                    $expressionSource.missingInputBehaviour = $MissingInputBehaviour
                }
                $body.sources += $expressionSource
            }
            elseif ($SourceMetaverseAttributeId) {
                # Attribute-based export mapping
                $order = 0
                foreach ($sourceId in $SourceMetaverseAttributeId) {
                    $body.sources += @{
                        order = $order
                        metaverseAttributeId = $sourceId
                    }
                    $order++
                }
            }
            elseif ($isGenerated) {
                # A generated mapping's base expression is optional: Sequence and Random token kinds stand
                # with no base value at all (the server validates this against -TokenKind).
            }
            else {
                Write-Error "-SourceMetaverseAttributeId or -Expression is required for export mappings."
                return
            }

            # Initial Export Only (#223), export mappings only.
            if ($InitialExportOnly) {
                $body.initialExportOnly = $true
            }

            $targetDescription = "CS Attribute $TargetConnectedSystemAttributeId"
        }

        # Create disabled (#1485). Sent only when asked for, so the server's default of enabled stands
        # otherwise.
        if ($PSBoundParameters.ContainsKey('Enabled')) {
            $body.enabled = $Enabled
        }

        # Keep unique (#2023). Sent only when asked for, so the server's default of on stands otherwise.
        if ($PSBoundParameters.ContainsKey('KeepUnique')) {
            $body.keepUnique = $KeepUnique
        }

        # "Generated Value" (Unique Value Generation, #242). Only the settings actually supplied are sent;
        # every omitted one leaves the server's own default standing (see SyncRuleMappingGeneration).
        if ($isGenerated) {
            $generation = @{}
            if ($PSBoundParameters.ContainsKey('TokenKind')) { $generation.tokenKind = $TokenKind }
            if ($PSBoundParameters.ContainsKey('SuffixStyle')) { $generation.suffixStyle = $SuffixStyle }
            if ($PSBoundParameters.ContainsKey('SuffixStart')) { $generation.suffixStart = $SuffixStart }
            if ($PSBoundParameters.ContainsKey('SequenceStart')) { $generation.sequenceStart = $SequenceStart }
            if ($PSBoundParameters.ContainsKey('SequenceIncrement')) { $generation.sequenceIncrement = $SequenceIncrement }
            if ($PSBoundParameters.ContainsKey('FixedWidth')) { $generation.fixedWidth = $FixedWidth }
            if ($PSBoundParameters.ContainsKey('OnWidthExceeded')) { $generation.onWidthExceeded = $OnWidthExceeded }
            if ($PSBoundParameters.ContainsKey('RandomFormat')) { $generation.randomFormat = $RandomFormat }
            if ($PSBoundParameters.ContainsKey('RandomLength')) { $generation.randomLength = $RandomLength }
            if ($PSBoundParameters.ContainsKey('Separator')) { $generation.separator = $Separator }
            if ($PSBoundParameters.ContainsKey('AttemptLimit')) { $generation.attemptLimit = $AttemptLimit }
            if ($PSBoundParameters.ContainsKey('NeverReuse')) { $generation.neverReuse = $NeverReuse }
            if ($PSBoundParameters.ContainsKey('CollisionRemediation')) { $generation.collisionRemediation = $CollisionRemediation }
            # Always sent as a JSON array, even for a single ID: the @() keeps one value from serialising as a
            # bare number.
            if ($PSBoundParameters.ContainsKey('ExcludeConnectedSystemId')) { $generation.exclusions = @($ExcludeConnectedSystemId) }
            $body.generation = $generation
        }

        if ($PSCmdlet.ShouldProcess("$targetDescription in Synchronisation Rule $SyncRuleId", "Create Mapping")) {
            Write-Verbose "Creating Synchronisation Rule Mapping for Synchronisation Rule: $SyncRuleId"

            try {
                $result = Invoke-JIMApi -Endpoint "/api/v1/synchronisation/sync-rules/$SyncRuleId/mappings" -Method 'POST' -Body $body

                Write-Verbose "Created Synchronisation Rule Mapping with ID: $($result.id)"

                # A raised -SequenceStart moves the target attribute's counter forward at save time (plan
                # decision 3); the response reports it rather than doing it silently.
                if ($result.Generation.SequenceSkippedAhead) {
                    $skip = $result.Generation.SequenceSkippedAhead
                    Write-Warning "Mapping $($result.Id)'s Sequence counter moved from $($skip.From) to $($skip.To) to honour the requested Sequence Start."
                }

                # Non-blocking warnings the save raised (for example, an Attribute Flow deriving a Metaverse
                # attribute that calls a function returning a different value each time, #1750). The save went
                # ahead; each warning is written as the server phrased it.
                foreach ($saveWarning in @($result.Warnings | Where-Object { $_ })) {
                    Write-Warning $saveWarning
                }

                $result
            }
            catch {
                Write-Error "Failed to create Synchronisation Rule Mapping: $_"
            }
        }
    }
}