> For the complete documentation index, see [llms.txt](https://docs.openiam.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.openiam.com/administration-guide/automation/synchronization-scripts.md).

# Edit a synchronization transformation script

Copy a shipped transformation script to your own versioned path, change it, attach it and prove it on three records — with the email and UPN removal worked end to end.

## What this is, and when you need it

Every synchronization run hands each source record to a Groovy transformation script, and that script decides what the resulting OpenIAM object looks like. Changing what a feed asserts — which attributes it writes, which rows it skips, whether a new person becomes a user or a new-hire request — means editing that script. There is no screen that does it for you.

The one rule that governs the whole job: **never edit a shipped script in place.** An upgrade replaces it, silently, and the feed resumes asserting the values you removed. Copy it to a path you own, edit the copy, and point the synchronization at the copy.

The contracts these scripts have to satisfy — the five script slots, the method signatures, the thirteen return codes and the base-class helpers — are in [Synchronization script contracts](/administration-guide/reference/synchronization-script-contracts.md). This page is how to change one safely.

## At a glance

|                          |                                                                                                                                                                  |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **You will end up with** | A customer-owned, versioned copy of the transformation script attached to one synchronization, proven against three records, with the shipped original untouched |
| **Where**                | **Administration → Groovy Manager** for the script; **Provisioning → Synchronization** for the **Transformation Rule** field on the configuration                |
| **You need first**       | Which script the configuration currently names, a non-production or inactive copy of the synchronization, and three source records chosen to cover the change    |
| **Takes**                | 20 minutes for the copy and the attach; longer for the three-record run and reading the report                                                                   |
| **Reversible**           | Yes — re-select the previous script on the configuration. But anything a test run wrote to OpenIAM or to a managed system stays written                          |

## Before you begin

* **The script the configuration actually names.** Open the synchronization and read **Transformation Rule**; do not assume it is the shipped example for that connector. Three shipped scripts contain an email and UPN block and they are not the same block.
* **A synchronization you can run without consequence** — a copy of the configuration with **Provision to target systems?** off, over a two- or three-row source. See [Change configuration safely](/administration-guide/start/change-safely.md).
* **Three source records** chosen for the change you are making: one that exercises it, one that does not, and one that already has the OpenIAM state your change stops maintaining.
* **A path you own in Groovy Manager**, outside the shipped tree — for example `customer/sync/user/ad`.

{% hint style="warning" %}
The transformation script is not optional and neither is the validation script. A run with either field empty aborts during configuration validation, before a single record is read, and produces no processed-record counts at all. And **Transformation Implementation** must be `TRANSFORMATION`: policy-based transformation is not implemented in this release, and `POLICY` fails the run with `OPERATION_NOT_SUPPORTED`.
{% endhint %}

## Edit and attach the script

{% stepper %}
{% step %}

### Read the current configuration, and note both script fields

**Provisioning → Synchronization**, open the configuration, and write down what **Transformation Rule** and **Source attribute names** name today. Those two travel together: for a connector-backed source the transformation only ever sees the attributes the attribute-names lookup script asked for, so editing one without the other is what leaves values still being fetched.
{% endstep %}

{% step %}

### Copy both scripts to a versioned customer path

In **Administration → Groovy Manager**, open each script, copy its source, and save it under your own path — `customer/sync/user/ad/TransformActiveDirRecord`, version `1.0`. Keep the class declaration and the `execute` signature exactly as the shipped script has them; a class that no longer matches its base class compiles and then fails at run time with `CANNOT_INSTANTIATE_GROOVY_CLASS`. See [Create a Groovy script](/administration-guide/automation/manage-groovy-scripts.md).
{% endstep %}

{% step %}

### Make the change in your copy

Save it. Saving compiles first and stores second, so a script that fails to compile is never stored: success is `Groovy script saved successfully`, audited `SAVE_GROOVY`. A compile failure returns the full stack trace under **Compilation Errors** on the tab.

A clean compile proves syntax and sandbox compliance and nothing else. It does not prove the script still satisfies the contract its caller expects.
{% endstep %}

{% step %}

### Attach your copy to the synchronization

Back on the configuration, set **Transformation Rule** to your script and **Source attribute names** to your copy of the attribute-names script, then save. The configuration stores the script's **ID**, so the version you select is pinned — a later branch does not become active on its own.
{% endstep %}

{% step %}

### Run three records, with provisioning off

Start the run from **Provisioning → Synchronization** and open the run report. Check the record counts first: a run that aborted on configuration validation shows no processed-record counts at all, which is how you tell it from a run that read records and failed them.

Then read the failure rows. They are three comma-separated fields — the match value, a single status letter (`F` for failure, `S` for success) and the error text. `TRANSFORMATION_RETURN_SKIP` in that third field means the script returned `SKIP`, which is the error skip; if you meant "this row is not mine", the return is `SKIP_OK`.
{% endstep %}

{% step %}

### Read the far end, not the log line

For at least one record, confirm the OpenIAM object is what you intended — the attribute, the email address, the login, the role. Then, only once that is right, turn **Provision to target systems?** back on and confirm the account in the managed system.
{% endstep %}
{% endstepper %}

{% hint style="info" %}
A saved script can take up to a minute to be picked up by a service that has already run it: `org.openiam.groovy.cache.ttl.ms` ships `60000`. If a test shows the old behaviour, wait it out before changing anything.
{% endhint %}

## Editing a shipped transformation script: removing the email and UPN block

A recurring question, in one form or another: *not every account in the directory has an email address, and the UPN is going to arrive from a separate cloud import instead — can the email and UPN block in the transformation script simply be commented out, without hurting the rest of the feed?*

Yes. But five things have to be true at the same time, and each of them is a separate place in the configuration.

### Nothing in OpenIAM requires a user to have an email address

OpenIAM's email validation returns immediately when a user has no addresses at all. Every rule below that point — blank address, malformed address, over-length address, duplicate address — applies only to an address the current save is adding or replacing, and nothing anywhere makes a `PRIMARY_EMAIL` mandatory on a user. A user with no email address saves, provisions and authenticates by password exactly like any other; create one in the Webconsole with the email field blank and you will see the save succeed.

What such a user *does* lose is a list of notifications and one authentication method, most of which fail silently. Read [What a user with no email address misses](/administration-guide/platform/mail-and-notifications.md#what-a-user-with-no-email-address-misses) before you decide the population is fine without addresses — the answer is usually yes for service and shared accounts, and no for anyone who has to approve or review anything.

### The block is not in one place, and the three shipped versions differ

Three shipped transformation scripts contain an email/UPN block, and they are not the same block. Find out which one your configuration actually names before editing anything:

| Shipped script                                             | Candidate attributes                                                                                                                                   | What the block writes                                                                                                                                                                                                                        |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sync/user/azure/AzureUserSampleTransformation_1.0.groovy` | `UserPrincipalName` only                                                                                                                               | **Both** `addUserAttribute(pUser, attrVal.getName(), attrVal.getValue())` and `addUserEmailAddress(...)` — so the UPN is stored twice, once as a user attribute named `UserPrincipalName` and once as the `PRIMARY_EMAIL` object             |
| `sync/user/ad/TransformActiveDirRecord_1.0.groovy`         | LDAP `mail` only                                                                                                                                       | `addUserEmailAddress(...)` only, with **no** `addUserAttribute`. The else-branch prints `mail attribute was not found` and does nothing else                                                                                                 |
| `sync/user/ad/ADPowerShellTransformation_1.0.groovy`       | A candidate chain: `EmailAddress`, then `mail`, then `UserPrincipalName`, then an opt-in `EMAIL_DOMAIN_FALLBACK` domain appended to the resolved login | A `syncEmail()` method that walks the candidates, checks `isEmailTaken` for a **new** user and takes the first free one, applies the primary candidate directly for an **existing** user, and assigns through a private `assignEmail` helper |

The PowerShell version is the one that most often surprises people: commenting out the `mail` lookup there does not stop an email being set, because `UserPrincipalName` is the third candidate and the constructed `<login>@<domain>` fallback is the fourth. Remove the call to `syncEmail(...)`, or the whole method, rather than one line inside it.

{% hint style="warning" %}
Copy the script to a customer-owned, versioned script path before you change it. A shipped script edited in place is replaced by the next upgrade, silently, and the feed resumes asserting the values you removed. See [Create a Groovy script](/administration-guide/automation/manage-groovy-scripts.md).
{% endhint %}

### Caveat 1: strip the attribute-request list too, or the values are still fetched

For a connector-backed source the transformation only sees what the attribute-names lookup script asked for. Leaving the request in place means the connector still retrieves and transports the values; the run is no cheaper, and the next person to read the pair of scripts cannot tell which layer is authoritative.

* `sync/user/ad/ADPowerShellAttributes_1.0.groovy` requests `EmailAddress` and `UserPrincipalName`.
* `sync/user/azure/AzureUserSyncAttributes_1.0.groovy` requests `UserPrincipalName`.

Remove the entries from your copy of that script as well. Anything absent from `getAttributes()` is absent from `columnMap`, with no error — which is exactly the effect you want here, and exactly the trap described under [`AttributesScript`](/administration-guide/reference/synchronization-script-contracts.md#attributesscript) when it happens by accident.

### Caveat 2: check that no other feed matches on email

`EMAIL` is a legal value of **OpenIAM field name** — it appears in that drop-down as **Email** — and the shipped Azure configuration uses precisely that, matching `EMAIL` on the OpenIAM side against `UserPrincipalName` on the source side. Read the pair on any synchronization's own screen under **OpenIAM field name** and **Source field name**.

So in a dual-source estate an email address is not only user data — it may be the correlation key of a different synchronization. Removing email from the directory feed can therefore break the *cloud* feed's matching, weeks later, in a run nobody changed. Before you edit, list every synchronization whose **OpenIAM field name** is `EMAIL` and confirm that none of them depends on the addresses this feed was supplying.

### Caveat 3: there are two independent writes in that block, and you probably want to keep one

`addUserAttribute` and `addUserEmailAddress` do unrelated things, and the Azure script calls both.

**`addUserAttribute(pUser, attrVal.getName(), attrVal.getValue())`** stores a user attribute whose name is the **source column** — literally `UserPrincipalName` — with `AttributeOperationEnum.ADD` on first sight and `REPLACE` when the value has changed. A blank name or blank value is skipped outright. The row is persisted to `USER_ATTRIBUTES` and shown in the Webconsole under **User Attributes**.

**`addUserEmailAddress`** writes the email object, and only the email object.

Keeping the `addUserAttribute` line while removing the `addUserEmailAddress` call is usually the right edit. The raw UPN stays searchable as a user attribute, nobody's `PRIMARY_EMAIL` is touched, and the attribute is exactly the correlation key a later cloud import needs — a **Custom Attribute** match on `UserPrincipalName` is a far better key than a mutable, reusable email address.

### Caveat 4: email addresses already imported are not removed

This is the caveat people get wrong, so state it plainly: **a run that stops setting an email address does not clear the addresses earlier runs set.**

Before merging, OpenIAM discards every email element on the incoming object whose operation is unset or `NO_CHANGE`, then applies deletes, inserts and updates to what is left — and it deletes only entries explicitly flagged `AttributeOperationEnum.DELETE`. There is no absent-means-delete behaviour anywhere on that path: an address the transformation says nothing about is an address that survives untouched. Prove it on one user by running the feed with the email block removed and re-reading their record; the address is still there.

There is no "clear email" switch on a synchronization configuration. To actually remove addresses an administrator has to do one of three things:

* fetch the existing address in the transformation and set `operation = AttributeOperationEnum.DELETE` on it, which is the only flag the merge acts on;
* remove the addresses by hand in the Webconsole; or
* remove them with a bulk operation.

Commenting out the block is therefore a decision about the *future*, not a cleanup. If the point of the exercise is to get rid of addresses the directory should never have supplied, that is a second, deliberate piece of work.

### Caveat 5: on an existing user the shipped call REPLACEs, it does not seed

`addUserEmailAddress` does not mean "add an address if there isn't one". For an existing user it finds the address with the same metadata type, compares the address, description, active, default and published values, and sets `AttributeOperationEnum.REPLACE` if any of them differs; only when no address of that metadata type exists does it `ADD`.

So a directory feed carrying email has been **overwriting** the primary address on every run, not seeding it once at creation. If any of those addresses were corrected in OpenIAM after the import, the feed has been undoing the correction on schedule. That is often the real reason to remove the block, and it is worth checking the audit trail before concluding the feed was harmless.

### The minimal safe edit

For the common case — the directory authoritative for the account, a separate cloud import authoritative for the UPN and the mailbox — the change is five things, applied through [the procedure above](#edit-and-attach-the-script):

1. **Copy both scripts** to a versioned customer path: the transformation script *and* the attribute-names lookup script. Editing one without the other is what leaves the values still being fetched.
2. **Remove the email write, keep the attribute write.** Comment out `addUserEmailAddress(...)` — or, in `ADPowerShellTransformation`, the call to `syncEmail(...)` — and leave `addUserAttribute(pUser, attrVal.getName(), attrVal.getValue())` in place. Where the shipped script has no `addUserAttribute` call, consider adding one, so the source value stays visible and searchable.
3. **Remove the attributes from your copy of the lookup script:** `EmailAddress` and `UserPrincipalName` from `ADPowerShellAttributes`, `UserPrincipalName` from `AzureUserSyncAttributes`.
4. **Confirm nothing else matches on email.** Any synchronization whose **OpenIAM field name** is `EMAIL` is now correlating on data this feed no longer maintains.
5. **Run three records on an inactive copy:** one account with an email address in the source, one without, and one that already has a `PRIMARY_EMAIL` in OpenIAM. Expected results: no email created for the first two, and the third's existing address **unchanged** — not blanked. The user attribute is present on all three.

## Verification

After a change to any of these scripts, run the synchronization against a two- or three-record source and check all of:

* **The synchronization report**, downloadable from the synchronization history. Failure rows carry the response code — look for `TRANSFORMATION_RETURN_SKIP` specifically, since that is what a stray `SKIP` produces.
* **Audit records.** `SYNCHRONIZATION` for the run, `SYNCHRONIZATION_THREAD` per thread, `SYNCHRONIZATION_RECORD` per record (with `AuditAttributeName.ORPHAN` = `false`), and `SYNCHRONIZATION_ORPHAN` for orphans. A `SKIP_OK` record appears as a *successful* `SYNCHRONIZATION_RECORD` with the note `Skipped with success`.
* **For an `INITIATE_*` return, a task — not a user.** The proof is a `makeRequestDecision` task in the expected approver's **SelfService → Inbox**, plus the request-type audit action for that flow. If you find the user created and no task, your script returned `NO_DELETE`.
* **For `NO_DELETE`, the object itself.** And, if **Provision to target systems?** is on, the account in the target: that one flag decides whether the record is provisioned or written to the OpenIAM database only.
* **The script version that ran.** Reopen the configuration and read the selected script back. Do not infer it from the Groovy Manager tree.

## When it goes wrong

Symptoms that come from the script's *contract* rather than from your edit — a workflow return in the wrong direction, a preprocessor returning `0`, a null `getAttributes()`, an unguarded `parseFlexibleDate`, a directory's year-9999 sentinel, an `init()` that runs once per record — are tabulated with their causes in [Synchronization script contracts](/administration-guide/reference/synchronization-script-contracts.md).

| Symptom                                                                             | Cause to check                                                                                                                                                                                  | Fix                                                                                                                                                                                                                                                                                                                          |
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Run aborts immediately, no records processed                                        | Configuration validation failed — no validation script, or no transformation script                                                                                                             | Set both; they are not optional                                                                                                                                                                                                                                                                                              |
| Run aborts immediately, no records processed, and a preprocessor script is attached | The preprocessor returned `FAIL` or `SKIP`, which stops the whole run. **`FAIL` is `0`**, so a script returning `0` out of batch-task habit aborts it                                           | Return `SyncConstants.SUCCESS` (`1`) — see [Pre-processor and post-processor scripts](/administration-guide/reference/synchronization-script-contracts.md#pre-processor-and-post-processor-scripts). The run sets `SYNCHRONIZATION_PRE_SRIPT_FAILURE`, misspelled in the product, so search for `SRIPT` rather than `SCRIPT` |
| Run fails with `OPERATION_NOT_SUPPORTED` before any record                          | **Transformation Implementation** is `POLICY`, and policy-based transformation is not implemented                                                                                               | Set it to `TRANSFORMATION`                                                                                                                                                                                                                                                                                                   |
| Every filtered row appears as an error                                              | The transform returns `SKIP` (`-1`) for out-of-scope rows                                                                                                                                       | Return `SKIP_OK` (`11`)                                                                                                                                                                                                                                                                                                      |
| Report shows `TRANSFORMATION_RETURN_SKIP`                                           | Same cause, this is the exact code written                                                                                                                                                      | As above                                                                                                                                                                                                                                                                                                                     |
| Users are not disabled although the script returns `DISABLE`                        | `DISABLE` takes the same branch as `NO_DELETE`; it changes nothing on its own                                                                                                                   | Set `pUser.status` in the script and return `NO_DELETE`                                                                                                                                                                                                                                                                      |
| Records fail with no message                                                        | The script threw; the engine converted it to `EXCEPTION`. Log lines are `Exception during transformation script execution` and `Transformation script return exception. Object will be skipped` | Read the stack trace in the service log; the usual culprit is `parseFlexibleDate`                                                                                                                                                                                                                                            |
| `columnMap` missing keys for a connector source                                     | The attribute-names lookup script's `getAttributes()` does not list the attribute                                                                                                               | Add it; there is no error for an unrequested attribute                                                                                                                                                                                                                                                                       |
| Duplicate logins created on re-import                                               | `addLogin` always uses `AttributeOperationEnum.ADD`                                                                                                                                             | Guard with `isLoginTaken(login, managedSysId, pUser.id)`                                                                                                                                                                                                                                                                     |
| Email still set after the `mail` lookup was commented out                           | `ADPowerShellTransformation`'s `syncEmail` method has four candidates, not one — `UserPrincipalName` and a constructed `<login>@<domain>` fallback follow `mail`                                | Remove the `syncEmail(...)` call, not a line inside it                                                                                                                                                                                                                                                                       |
| Email addresses did not disappear after the block was removed                       | Absent means no change, not delete: OpenIAM discards unflagged elements from the incoming object and removes only entries explicitly flagged `DELETE`                                           | Clear them deliberately — set `AttributeOperationEnum.DELETE`, or remove them manually or by bulk operation                                                                                                                                                                                                                  |
| A different feed stopped matching after email was removed from this one             | The other configuration's **OpenIAM field name** is `EMAIL` — as the shipped Azure config is                                                                                                    | Move that feed to an immutable key before removing the data it correlates on                                                                                                                                                                                                                                                 |
| Rows are being excluded and nothing anywhere records it                             | A validation rule returning `NOT_VALID`/`SKIP` increments `FAIL_COUNT` only; its one log line prints at debug level, so ordinarily not at all                                                   | Exclude in the source query, or return `SKIP_OK` from the transformation — see [Choosing where to exclude a record](/administration-guide/reference/synchronization-script-contracts.md#choosing-where-to-exclude-a-record)                                                                                                  |
| A counter or de-duplication set in a script gives wrong results                     | One script instance is created per part, parts are 1,000 rows by default and are dispatched as separate messages                                                                                | Aggregate in the source query; use `getNextValueInSeries` for shared sequences — [one instance per part](/administration-guide/reference/synchronization-script-contracts.md#one-script-instance-per-part)                                                                                                                   |
| Script edit has no effect for about a minute                                        | `org.openiam.groovy.cache.ttl.ms` = 60000                                                                                                                                                       | Wait it out — see [Create a Groovy script](/administration-guide/automation/manage-groovy-scripts.md)                                                                                                                                                                                                                        |
| `CANNOT_INSTANTIATE_GROOVY_CLASS` at run time                                       | The class does not extend `AbstractUserTransformScript` / implement `ValidationScript`, or has no no-arg constructor                                                                            | Copy the class declaration from the shipped script for the same field                                                                                                                                                                                                                                                        |

## Related

* [Synchronization script contracts](/administration-guide/reference/synchronization-script-contracts.md) — the five script slots, every return code, the base-class helpers and a complete new-hire transform
* [Create a Groovy script](/administration-guide/automation/manage-groovy-scripts.md) — versioning, branching, promoting and rolling back the script
* [Set up a synchronization](/administration-guide/lifecycle/synchronize-and-import-data.md) — the source, the match rule and the schedule
* [Mail and notifications](/administration-guide/platform/mail-and-notifications.md#what-a-user-with-no-email-address-misses) — what a user left without an email address stops receiving
* [Change configuration safely](/administration-guide/start/change-safely.md) — why a first run points at three records, not a directory

*Applies to OpenIAM 2026.8.1. Last reviewed 7 August 2026.*


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.openiam.com/administration-guide/automation/synchronization-scripts.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
