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
        Creates a new Synchronisation Rule Mapping (attribute flow rule) in JIM.
 
    .DESCRIPTION
        Creates a new attribute flow mapping for a Synchronisation Rule.
        For Import rules, this maps Connected System attributes to Metaverse attributes.
        For Export rules, this maps Metaverse attributes to Connected System attributes.
        Alternatively, an expression can be used as the source for dynamic value generation.
 
    .PARAMETER SyncRuleId
        The unique identifier of the Synchronisation Rule to add the mapping to.
        Also accepts pipeline input via the Id property.
 
    .PARAMETER TargetMetaverseAttributeId
        For Import rules: The ID of the Metaverse attribute that will receive the value.
 
    .PARAMETER TargetConnectedSystemAttributeId
        For Export rules: The ID of the Connected System attribute that will receive the value.
 
    .PARAMETER SourceConnectedSystemAttributeId
        For Import rules: The ID of the Connected System attribute to use as the source.
        Can be a single value or an array for multiple sources.
        Mutually exclusive with -Expression.
 
    .PARAMETER SourceMetaverseAttributeId
        For Export rules: The ID of the Metaverse attribute to use as the source.
        Can be a single value or an array for multiple sources.
        Mutually exclusive with -Expression.
 
    .PARAMETER MissingInputBehaviour
        For expression mappings: what to do when an attribute the expression reads has no value on the object
        being synchronised. Omit for EvaluateAnyway, which evaluates the expression regardless and is what JIM
        has always done.
        - EvaluateAnyway: evaluate with the input absent and contribute whatever it returns.
        - ContributeNoValue: do not evaluate; contribute nothing, resolved by Attribute Priority. Not an error.
        - FailMapping: do not evaluate; record an ExpressionMissingInput error. The object's other attributes
          still flow.
        - FailObject: do not evaluate anything for the object; it is errored and left untouched.
 
    .PARAMETER Expression
        An expression to evaluate for the source value.
        Uses DynamicExpresso syntax with mv["AttributeName"] and cs["AttributeName"] for attribute access.
        Example: '"CN=" + EscapeDN(mv["Display Name"]) + ",OU=Users,DC=domain,DC=local"'
 
    .PARAMETER PreserveWhitespace
        For import mappings only. By default JIM treats a whitespace-only or empty imported text value as no
        value (it does not flow, and clears any existing Metaverse value). Use this switch to preserve
        whitespace as a literal value instead.
 
    .PARAMETER TrimWhitespace
        For import mappings only. Removes leading and trailing whitespace from the imported text value.
 
    .PARAMETER CollapseInternalWhitespace
        For import mappings only. Collapses runs of internal whitespace down to a single space.
 
    .PARAMETER CaseNormalisation
        For import mappings only. Normalises the case of the imported text value: None (default), Upper, Lower
        or Title.
 
    .PARAMETER NullIsValue
        For import mappings only. When this Synchronisation Rule applies to a Connected System Object that is
        joined to a Metaverse Object, but contributes no value, treat that as an authoritative "no value":
        clear the Metaverse Object attribute value rather than falling back to a lower-priority contributor.
        A rule that results in no opinion (rule disabled, Connected System Object not joined, or out of scope)
        is skipped regardless. Off by default. Change it on an existing mapping with
        Set-JIMMetaverseAttributePriority or Move-JIMMetaverseAttributePriority.
 
    .PARAMETER InitialExportOnly
        For export mappings only. When set, the mapping only flows during the initial provisioning (Create)
        export; afterwards the target attribute is unmanaged by JIM on that Connected System Object and
        Drift Correction does not re-assert it.
 
    .PARAMETER Enabled
        Whether the mapping is evaluated by synchronisation from the moment it is created. Omit to create the
        mapping enabled (the server default). Supply $false to create it disabled, so it can be ordered and
        reviewed before it starts flowing values; a disabled mapping is skipped in both directions until it
        is re-enabled with Set-JIMSyncRuleMapping -Enabled $true.
 
    .PARAMETER Generate
        Makes this a "Generated Value" mapping (Unique Value Generation, #242): the mapping's value is its base
        expression (optional; supply with -Expression) plus a uniqueness token. Requires either
        -TargetMetaverseAttributeId (an import mapping) or -TargetConnectedSystemAttributeId (an export
        mapping). Exclusions and Collision Remediation are not configurable from any surface in this release.
 
    .PARAMETER TokenKind
        Which uniqueness token the mapping appends. Omit for OnlyIfTaken (the server default): the base value
        is tried first, and only suffixed when it is already taken.
        - OnlyIfTaken: try the base value; append a collision suffix only when needed. Requires -Expression.
        - Sequence: always append the next number from the target attribute's counter. Never reused.
        - Random: always append a cryptographically random token (-RandomFormat, -RandomLength).
 
    .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
        The maximum number of candidates tried, per object per synchronisation run, before generation fails
        hard for that object. Omit for 1000.
 
    .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.
 
    .OUTPUTS
        PSCustomObject representing the created Synchronisation Rule Mapping. A generated mapping's Generation
        property carries its uniqueness token settings; Generation.SequenceSkippedAhead is present only when
        -SequenceStart raised the target attribute's counter on this save. Warnings lists any non-blocking
        warnings the save raised (empty when there were none); each is also written with Write-Warning.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 1 -TargetMetaverseAttributeId 5 -SourceConnectedSystemAttributeId 10
 
        Creates an import mapping that flows data from CS attribute 10 to MV attribute 5.
 
    .EXAMPLE
        New-JIMSyncRuleMapping -SyncRuleId 2 -TargetConnectedSystemAttributeId 15 -SourceMetaverseAttributeId 8
 
        Creates an export mapping that flows data from MV attribute 8 to CS 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.
 
    .LINK
        Get-JIMSyncRuleMapping
        Remove-JIMSyncRuleMapping
        Get-JIMSyncRule
        Get-JIMGeneratedValueSequence
        Restart-JIMGeneratedValues
        Test-JIMExpression
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([PSCustomObject])]
    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,

        # 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
    )

    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
        }

        # "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 }
            $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: $_"
            }
        }
    }
}