> 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/reference/synchronization-script-contracts.md).

# Synchronization script contracts

Every synchronization script slot, the exact method signatures, all thirteen transform return codes with their numeric values and effects, and the base-class helpers.

## What this is, and when you need it

An authoritative source hands OpenIAM rows, and OpenIAM has no opinion about what any of them mean. Whether a row is a new hire, a rename, a transfer or a leaver, whether it is fit to process at all, and what the resulting user should look like are all decided by Groovy you supply. So if you have a feed, you are writing scripts, and the thing you need is the exact contract each one has to satisfy.

This page is that contract: the interface each script implements, the exact method signature, and the complete set of values a transform script may return with what each one causes. Editing and attaching a script is [Edit a synchronization transformation script](/administration-guide/automation/synchronization-scripts.md); configuring the source, the match rule and the schedule is [Set up a synchronization](/administration-guide/lifecycle/synchronize-and-import-data.md).

Six of the thirteen transform return codes start an approval workflow. That is how joiner, mover and leaver automation is actually built in OpenIAM — a transform script returning `INITIATE_NEW_HIRE_WORKFLOW` instead of writing the user directly is the difference between "the HR feed creates accounts" and "the HR feed raises a new-hire request that someone approves". Nothing in the Webconsole exposes this choice; it exists only as a return value.

## The five scripts and where each is attached

Every one is stored as a script **ID** in a `SYNCH_CONFIG` column. You pick the script by name in the Webconsole field named below; OpenIAM records its ID, then loads and instantiates it from the Groovy Manager on each run.

| Column                        | Webconsole field       | Interface to implement    | Required?              |
| ----------------------------- | ---------------------- | ------------------------- | ---------------------- |
| `VALIDATION_SCRIPT_ID`        | Validation Rule        | `ValidationScript`        | Yes — see below        |
| `TRANSFORMATION_SCRIPT_ID`    | Transformation Rule    | `TransformScript<T>`      | Yes — see below        |
| `ATTR_NAMES_LOOKUP_SCRIPT_ID` | Source attribute names | `AttributesScript`        | Connector sources only |
| `PREPROCESSOR_SCRIPT_ID`      | Preprocessor Script    | `SyncPreprocessorScript`  | No                     |
| `POSTPROCESSOR_SCRIPT_ID`     | Postprocessor Script   | `SyncPostprocessorScript` | No                     |

Clone a shipped script of the same kind from the Groovy Manager rather than starting from an empty file: the shipped examples already carry the `import` lines each interface needs, and getting one wrong is the most common cause of the compile failures described below. The procedure is [Edit a synchronization transformation script](/administration-guide/automation/synchronization-scripts.md).

{% hint style="warning" %}
The validation and transformation scripts are effectively mandatory. OpenIAM refuses to start a run when **Validation Rule** is empty or no transformation script is attached — the run aborts during configuration validation, before a single record is read. Separately, **Transformation Implementation** must be `TRANSFORMATION`: policy-based transformation is not implemented in this release, and setting the field to `POLICY` fails the run with `OPERATION_NOT_SUPPORTED`.

Confirm both by starting the synchronization from **Provisioning → Synchronization** and opening the run report: a run aborted for either reason produces no processed-record counts at all, which distinguishes it from a run that read records and failed them.
{% endhint %}

## The method contract

Everything else on this page hangs off two method signatures. `TransformScript` declares exactly these:

{% code overflow="wrap" %}

```java
int execute(LineObject rowObj, T pUser);
void init();
```

{% endcode %}

The interface is `TransformScript<T extends ProvisionObject>`, so the second argument is a `ProvisionUser` on a user synchronization, a `ProvisionGroup` on a group synchronization, and so on — which is why you extend `AbstractUserTransformScript` rather than implementing the interface directly. `execute` returns one of the thirteen `int` constants tabulated below. `init()` returns nothing.

There is only one buildable implementation style. OpenIAM instantiates a transformation script only when **Transformation Implementation** is `TRANSFORMATION`; with `POLICY` it fails the run with `OPERATION_NOT_SUPPORTED` before instantiating anything. A policy-driven transformation is not a supported alternative to the code on this page; it is a value that aborts the run.

### One script instance per part

A script cannot accumulate state, because OpenIAM builds a fresh transform script for **each part of the run**, not once for the run. It cuts the source records into parts of **Records per Batch** rows — the field ships **1,000** — and dispatches each part as its own message on the broker, potentially to a different thread and potentially to a different synchronization service instance. Each part gets its own script object.

You can see this on a completed run: with **Records per Batch** at `1000` and a 3,000-row feed, the run report shows three `SYNCHRONIZATION_THREAD` audit records, one per part.

{% hint style="danger" %}
**Do not accumulate state in a transformation script.** A counter, a "seen" set, a running total or a first-row header captured in a field is visible only to the 1,000-or-fewer records that happen to share that script instance, and two parts may run concurrently. Nothing merges the instances or orders the parts. If a rule genuinely needs to see the whole feed — de-duplicating several rows per person, for example — the aggregation belongs in the source query or in a pre-processing step, not in `execute`. Use `getNextValueInSeries` / `getNextFormattedValueInSeries` when you need a shared sequence, because those go through `counterRabbitMQService` rather than through a field.
{% endhint %}

## The per-record sequence

For each source record, OpenIAM does exactly this:

1. Runs your validation script's `isValid(rowObj)`. `NOT_VALID` or `SKIP` aborts the record and marks the metric `FAILURE`.
2. Matches the record to an existing object; if none matches, it creates a new empty `ProvisionObject`.
3. Takes a copy of the object as it stands, which is what the run report later diffs against.
4. For each transform script, calls `init()`, then `setNewUser` with `true` when the matched object has no ID, then `execute(rowObj, provisionObject)`.
5. Handles orphans.
6. Fails the record with `TRANSFORMATION_RETURN_SKIP` on a `SKIP` return; fails it silently on `EXCEPTION`.
7. Acts on the returned code — the step that gives every other code the meaning tabulated below.

{% hint style="info" %}
`init()` is called **once per record**, immediately before every `execute` — not once per run, whatever its name suggests. Do not put expensive setup (a query, a full role list) in `init()`; put it in a field initialised lazily, or accept that it runs for every row. To confirm on your own deployment, log a line from `init()` and count the occurrences in the synchronization service log against the run report's record count: they will match.
{% endhint %}

## TransformScript return codes

`TransformScript<T extends ProvisionObject>` declares thirteen `int` constants, so a subclass of `AbstractUserTransformScript` can use the bare names. The numeric values are load-bearing: a script that returns a raw `-1` (as the shipped CSV example does in its catch block) is returning `SKIP`.

| Constant                                | Value | What the engine does                                                                                                                                                                                                                                                                                                                                        |
| --------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SKIP`                                  | `-1`  | **Record fails.** Adds an error row to the sync report reading `TRANSFORMATION_RETURN_SKIP`, logs `Transformation script return TransformScript.SKIP` to the synchronization service log, writes a failure audit record and sets the metric to `FAILURE`. Nothing is saved. This is the *error* skip.                                                       |
| `NO_DELETE`                             | `0`   | **Save.** Provisions the object if the config's **Provision to target systems?** is on, otherwise writes it to the OpenIAM database only. The normal success return.                                                                                                                                                                                        |
| `DELETE`                                | `1`   | Deletes the object — **only if it already has an ID.** For a new object, `DELETE` does nothing at all, silently.                                                                                                                                                                                                                                            |
| `DISABLE`                               | `2`   | Takes the *same* branch as `NO_DELETE`: provision-or-save. It does **not** disable anything by itself.                                                                                                                                                                                                                                                      |
| `ENABLE`                                | `3`   | Also the same branch as `NO_DELETE`. It does **not** enable anything by itself.                                                                                                                                                                                                                                                                             |
| `EXCEPTION`                             | `4`   | **Record fails**, metric `FAILURE`, no save, no extra error row. This is also what the engine substitutes when your `execute` throws — it catches the throwable, writes an audit record with `Exception during transformation script execution`, logs `Transformation script return exception. Object will be skipped`, and continues with the next record. |
| `INITIATE_NEW_HIRE_WORKFLOW`            | `5`   | Raises a `NEW_HIRE_WITH_APPROVAL` request. **Only when the object is new** — no ID.                                                                                                                                                                                                                                                                         |
| `INITIATE_EDIT_USER_WORKFLOW`           | `6`   | Raises an `EDIT_USER` request. **Only for an existing object.**                                                                                                                                                                                                                                                                                             |
| `INITIATE_REVOKE_ACCESS_WORKFLOW`       | `7`   | Raises a revoke-access request covering the roles and groups on the object, with comment `Please confirm revocation of following access items.` and description `Revoke access called from sync.` **Existing object only.**                                                                                                                                 |
| `INITIATE_CHANGE_POSITION_WORKFLOW`     | `8`   | Raises a `REVIEW_ASSESS_ON_POSITION_CHANGE` request, description `Position was changed for user <displayName>`. **Existing object only.**                                                                                                                                                                                                                   |
| `INITIATE_ADD_USER_AND_INVITE_WORKFLOW` | `9`   | Raises an `ADD_USER_AND_INVITE` request. **New object only.**                                                                                                                                                                                                                                                                                               |
| `INITIATE_TERMINATE_USER_WORKFLOW`      | `10`  | Raises a terminate-user request whose end date is taken from `pUser.accessRightEndDate`. **Existing object only.**                                                                                                                                                                                                                                          |
| `SKIP_OK`                               | `11`  | **Record is skipped and counted as a success.** Writes an audit record with the text `Skipped with success`. This is the return you want for "this row is not for me".                                                                                                                                                                                      |
| *anything else*                         | —     | Audits `OPERATION_NOT_SUPPORTED` with the text `Invalid` , logs `Operation not supported`, saves nothing.                                                                                                                                                                                                                                                   |

To confirm any row of that table on your own deployment, return the constant from a transform script against a single test record and read the run report and the audit trail together: the report carries the error text and the counts, the audit record carries the action and the message. The numeric values are the contract — a script that returns a bare `-1` is returning `SKIP` whether it names it or not.

### Four consequences worth stating plainly

**`SKIP` is a failure and `SKIP_OK` is not.** They are the same operation with opposite reporting. Filtering rows with `SKIP` produces a synchronization report full of errors and a run that looks broken. Use `SKIP_OK` (`11`).

**`DISABLE` and `ENABLE` do not disable or enable.** OpenIAM treats all three of `NO_DELETE`, `DISABLE` and `ENABLE` as the same instruction: provision or save. To disable a user you set `pUser.setStatus(UserStatusEnum.INACTIVE)` (or `DELETED`, `TERMINATE`) inside the script and return `NO_DELETE`. The two constants exist for readability and for scripts that also want to be portable to another caller; they carry no engine behaviour of their own.

**The `INITIATE_*` codes are direction-sensitive, and the wrong direction fails quietly.** When a workflow code arrives for the wrong kind of object, OpenIAM writes an audit record reading `OPERATION_NOT_SUPPORTED` and logs `Operation <n> not supported for <new|existing> user` to the synchronization service log — and nothing else happens. Returning `INITIATE_NEW_HIRE_WORKFLOW` for a record that matched an existing user is therefore not an error you will see in the UI; it is a record that silently did nothing. Gate every workflow return on `isNewUser`.

**A workflow return means no direct save.** The choice is exclusive: an `INITIATE_*` code starts the process and nothing is provisioned or written to the database by the run. The user is created or changed on approval, not by the synchronization. If nobody approves, nothing happens — and with the shipped approver defaults, "nobody" is `sysadmin`. See [Approval flows](/administration-guide/requests/approval-flows.md#the-shipped-default-approver-resources).

## `isNewUser` and `setNewUser`

Declared on `TransformScript` and implemented on `AbstractTransformScript`:

{% code overflow="wrap" %}

```java
boolean isNewUser();
void setNewUser(boolean isNewUser);
```

{% endcode %}

`AbstractTransformScript` holds the value in a `protected boolean isNewUser` that defaults to `false`, so in Groovy you read it as the bare property `isNewUser` — which is exactly how the shipped `CSVSampleTransformationScript_1.0.groovy` does it (`if (isNewUser) { fillEmail(...) }`). Open that script in the Groovy Manager to see the pattern.

**You never call `setNewUser` yourself.** OpenIAM calls it immediately before `execute`, passing `true` when the matched object has no ID. So `isNewUser` is *the* test for "did the match rule find an existing identity" — it is the same condition OpenIAM later uses to decide whether an `INITIATE_*` code is supported.

## What else is on the base class

OpenIAM injects every field below into your script before it runs. They are declared `protected` on `AbstractTransformScript`, so they are bare properties in Groovy.

| Field                                                                                                                                                                                                                                        | Type                                                          | Notes                                                                                                                                          |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `context`                                                                                                                                                                                                                                    | `ApplicationContext`                                          | Always injected; `getBean(...)` for anything not listed here                                                                                   |
| `config`                                                                                                                                                                                                                                     | `SynchConfig`                                                 | The whole configuration. `config.getManagedSysId()`, `config.getMatchSrcFieldName()`, `config.isProvision()` are the ones scripts actually use |
| `synchConfigId`                                                                                                                                                                                                                              | `String`                                                      |                                                                                                                                                |
| `isNewUser`                                                                                                                                                                                                                                  | `boolean`                                                     | See above                                                                                                                                      |
| `log`                                                                                                                                                                                                                                        | SLF4J logger                                                  | Static, named for `AbstractTransformScript` — anything you log through it lands in the synchronization service log under that category         |
| `CERTIFIED_RIGHT_SET`                                                                                                                                                                                                                        | `Set<String>`                                                 | Pre-built `["IS_CERTIFIED"]`, for passing as the `rights` argument                                                                             |
| `rolesByName` `rolesById` `groupsByName` `groupsById` `resourcesByName` `resourcesById` `usersByLogin` `usersById` `organizationByName` `organizationById` `managedSystemByName` `managedSystemById` `resourceTypeByName` `resourceTypeById` | `HashMap`                                                     | Per-part caches. `managedSystem*` and `resourceType*` arrive **pre-populated**; the rest start empty and fill as the `get*` helpers run        |
| `metadataTypesByGroupingAndName`                                                                                                                                                                                                             | `EnumMap<MetadataTypeGrouping, HashMap<String,MetadataType>>` |                                                                                                                                                |
| `userManager`                                                                                                                                                                                                                                | `UserRabbitMQService`                                         | Note the name — the binding key is `userManager`, not `userRabbitMQService`                                                                    |
| `roleRabbitMQService` `groupRabbitMQService` `resourceRabbitMQService` `organizationRabbitMQService` `managedSystemRabbitMQService` `counterRabbitMQService` `metadataTypeRabbitMQService` `loginMQService`                                  | MQ services                                                   |                                                                                                                                                |

### Lookup helpers on `AbstractTransformScript`

All cache-then-query, all return `null` when not found:

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```java
protected Role         getRoleByName(final String name, final String managedSysId)
protected Role         getRoleById(final String id)
protected Group        getGroupByName(final String name, final String managedSysId)
protected Group        getGroupById(final String id)
protected Resource     getResourceByName(final String name)
protected Resource     getResourceById(final String id)
protected User         getUserByLogin(final String login)
protected User         getUserByLogin(final String login, final String mSysId)
protected User         getUserById(final String id)
protected User         getUserByAttribute(final String attributeName, final String attributeValue)
protected Organization getOrganizationByName(final String name)
protected Organization getOrganizationById(final String id)
protected ManagedSysDto getManagedSystemByName(final String name)
protected ManagedSysDto getManagedSystemById(final String id)
protected ResourceType getResourceTypeByName(final String name)
protected ResourceType getResourceTypeById(final String id)
protected MetadataType getMetadataTypeByNameAndGrouping(final String name, final MetadataTypeGrouping grouping)
protected String       getNextFormattedValueInSeries(final String format, final String key)
protected Long         getNextValueInSeries(String key, long offset)
protected void         safeString(final Map<String, Attribute> col, final String key, final Consumer<String> action)
public    ObjectOwner  getOwnerObject(String type, String name)
public    ObjectAdmin  getAdminObject(String type, String name)
```

{% endcode %}

Three of those have behaviour you need to know:

* **`getUserByLogin(login)`** searches managed system `"0"` (the OpenIAM repository) with `UserCollection.ALL`, so it is expensive. `getUserByLogin(login, mSysId)` searches the named system with no collections, and returns `null` — with the warning `User collection was empty or contained more then one element` — if more than one matches. `getUserByAttribute` behaves the same way.
* **`getResourceTypeByName(name)`** looks up by the resource type's **`DESCRIPTION`**, not its ID. Read the value to pass under **Access Control → Resource Type**, in the description column, not the identifier column.
* **`getNextFormattedValueInSeries("%05d", "EMPLOYEE_ID")`** increments a shared counter through a shared counter and formats it. The counter returns its stored value **plus one**, and an unknown key starts at `1`, so the first three calls return `"00001"`, `"00002"`, `"00003"` — not `"00000"`. The product's own inline documentation says `"00000"` first; it is wrong. This is the supported way to generate a sequential identifier during a sync.

## AbstractUserTransformScript: the 16 user helpers

`AbstractUserTransformScript` extends `AbstractTransformScript<ProvisionUser>`. Extend it for a user sync; `AbstractGroupTransformScript` and `AbstractRoleTransformScript` are the group and role equivalents. Exact signatures:

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```java
protected void addUserPhone(final ProvisionUser pUser, final PhoneDTO phone)
protected void addUserAddress(final ProvisionUser pUser, final Address address)
protected void addUserEmailAddress(final ProvisionUser pUser, final EmailAddress emailAddress)

protected void addUserGroupByName(final ProvisionUser pUser, final String groupName,
        final String managedSysId, final Set<String> rights,
        final Date startDate, final Date endDate, final String description)
protected void addUserRoleByName(final ProvisionUser pUser, final String roleName,
        final String managedSysId, final Set<String> rights,
        final Date startDate, final Date endDate, final String description)
protected void addUserGroupByAttribute(final ProvisionUser pUser, final String attributeName,
        final String attributeValue, final Set<String> rights,
        final Date startDate, final Date endDate, final String description)
protected void addUserRoleByAttribute(final ProvisionUser pUser, final String attributeName,
        final String attributeValue, final Set<String> rights,
        final Date startDate, final Date endDate, final String description)
protected void addUserOrganization(final ProvisionUser pUser, final String orgName,
        final String orgTypeID, final Set<String> rights,
        final Date startDate, final Date endDate)

protected void addUserAttribute(final ProvisionUser pUser, final String name, final String value)

protected void addSupervisorToUser(final ProvisionUser pUser, String searchFiled,
        final String searchValue, final MetadataType supervisorType, final String managedSysId)
protected Optional<User> findUserBy(String searchFiled, final String searchValue,
        final String managedSysId)

protected void addLogin(final ProvisionUser pUser, final String loginValue, final String managedSysId)
protected boolean isLoginTaken(final String loginValue, final String managedSysId,
        final String excludeUserId)
protected boolean isEmailTaken(final String emailAddress)

protected static Date parseFlexibleDate(final String s)
protected static boolean isValidExpiry(final Date date)
```

{% endcode %}

Behaviour that is not obvious from the signature:

| Helper                                     | What to know                                                                                                                                                                                                                                                                                                                                                                              |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `findUserBy` / `addSupervisorToUser`       | `searchFiled` (note the product's spelling) is lower-cased and matched against exactly two literals: **`"employeeid"`** and **`"principal"`**. Any other value logs `Please specify valid searchFiled to find manager [employeeid or principal]` and returns `Optional.empty()`. `managedSysId` is only used by the `principal` branch. A match count other than exactly 1 returns empty. |
| `addLogin`                                 | Always sets `AttributeOperationEnum.ADD` and `active = true`. Call it only when you know the login is absent — for an existing user, guard with `isLoginTaken`.                                                                                                                                                                                                                           |
| `isLoginTaken`                             | Excludes `excludeUserId` so a re-import does not collide a user with themselves. **Fails open**: any service exception logs `Login uniqueness check failed for '<login>' — assuming not taken` and returns `false`.                                                                                                                                                                       |
| `isEmailTaken`                             | Same fail-open behaviour, logging `Email uniqueness check failed for '<address>' — assuming not taken`.                                                                                                                                                                                                                                                                                   |
| `parseFlexibleDate`                        | Tries, in order, `MM/dd/yyyy HH:mm:ss`, `yyyy-MM-dd'T'HH:mm:ss`, `yyyy-MM-dd HH:mm:ss`, `yyyy-MM-dd`. **Throws `IllegalArgumentException`** if none match — it does not return null, so an unparseable date in one row fails that record with `EXCEPTION` unless you catch it.                                                                                                            |
| `isValidExpiry`                            | `true` only for a non-null date **before 1 January 9999**. Directory systems encode "never expires" as year 9999 or `Long.MAX_VALUE`; use this before writing `pUser.lastDate` so a sentinel does not become a real end date.                                                                                                                                                             |
| `addUserGroupByName` / `addUserRoleByName` | Resolve through `getGroupByName` / `getRoleByName`, which are scoped by `managedSysId`. Passing `null` for `managedSysId` searches with a null managed-system filter — which is what the shipped CSV script does for its default role.                                                                                                                                                    |

## `ValidationScript`

`ValidationScript` — three constants and one method:

{% code overflow="wrap" %}

```java
public interface ValidationScript {
    int VALID     = 1;
    int NOT_VALID = 2;
    int SKIP      = 3;

    int isValid(LineObject rowObj);
}
```

{% endcode %}

OpenIAM treats **`NOT_VALID` and `SKIP` identically** — both abort the record and set the metric to `FAILURE`. Only `VALID` continues. There is no "valid-but-skip" on this interface; if you want a filtered row counted as a success, return `VALID` here and `SKIP_OK` from the transform script.

Anything other than `VALID`, `NOT_VALID` or `SKIP` is treated as valid. And a *throw* from `isValid` is caught, audited with `Error during record validation`, logged as `Cannot determine error`, and treated as a failure.

This interface has no base class. Implement it directly, as `sync/user/csv/CSVSampleValidationScript_1.0.groovy` does:

{% code overflow="wrap" %}

```groovy
class CSVSampleValidationScript implements ValidationScript {
    ApplicationContext context
    public int isValid(LineObject rowObj) { return ValidationScript.VALID }
}
```

{% endcode %}

Declare the `context` field yourself if you need it — OpenIAM injects it only onto a field that already exists on your class.

### Every shipped validation script is a stub, and the field is mandatory

This is worth stating flatly, because "put it in a validation rule" is common advice and it always means *write one*. All four shipped validation scripts return `VALID` unconditionally, whatever the record contains:

| Shipped script                                         | What its `isValid` does                           |
| ------------------------------------------------------ | ------------------------------------------------- |
| `sync/user/ad/ADValidation_1.0.groovy`                 | `return ValidationScript.VALID`, and nothing else |
| `sync/user/ad/ADPowerShellValidation_1.0.groovy`       | Prints every column, then returns `VALID`         |
| `sync/user/csv/CSVSampleValidationScript_1.0.groovy`   | Same as `ADValidation`                            |
| `sync/user/azure/AzureUserSampleValidation_1.0.groovy` | A one-line method body returning `VALID`          |

Open any of them in the Groovy Manager to confirm; the whole method is a few lines and there is no conditional in it.

Meanwhile the **Validation Rule** field (`SYNCH_CONFIG.VALIDATION_SCRIPT_ID`) is **required by the Webconsole**: leave it empty and the save is refused with the field's own validation message, and a run whose named script is missing aborts during configuration validation.

So every synchronization has a validation rule, and unless someone wrote one, that rule is a pass-through. If you have been told a record "must be being rejected by validation", check which script the configuration actually names before you look for the rule that rejected it.

## Choosing where to exclude a record

Three mechanisms keep a source row out of OpenIAM. They differ far less in what they exclude than in what they leave behind, and that is the only basis on which to choose.

| Mechanism                                                       | Where you set it                                                                                                                                                                                                                                | What the run shows                                                                                                                                                                                                                                                                                           |
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Source-side filter**                                          | The synchronization's `QUERY` — label **SQL Query / Directory Filter** — with `baseDn` and `searchScope`; and on the managed system, `MNG_SYS_OBJECT_MATCH.SEARCH_FILTER`, `BASE_DN`, `SEARCH_BASE_DN` and `KEY_FIELD`, label **Search Filter** | **Nothing at all.** The row is never read, never counted, never audited, never reported. Cheapest to run and the only mechanism with no trail whatsoever — so document the filter itself, because the run cannot.                                                                                            |
| **Validation rule returning `NOT_VALID` (`2`) or `SKIP` (`3`)** | The Validation Rule script                                                                                                                                                                                                                      | A `FAIL_COUNT` increment, and that is all. OpenIAM treats both codes identically, writes **no** error row and **no** audit entry, and its one log line — `Validation return code %d. Stop with current object` — prints only at debug level, so on a normally configured deployment it never appears at all. |
| **Transformation returning `SKIP` (`-1`)**                      | The Transformation Rule script                                                                                                                                                                                                                  | A loud failure: the report row `<match value>,F,TRANSFORMATION_RETURN_SKIP`, the log line `Transformation script return TransformScript.SKIP`, and a failed audit record.                                                                                                                                    |
| **Transformation returning `SKIP_OK` (`11`)**                   | The Transformation Rule script                                                                                                                                                                                                                  | A **successful** record carrying the audit note `Skipped with success`.                                                                                                                                                                                                                                      |

For *deliberate* exclusion the answer is a source-side filter when the rule can be expressed in the query, and `SKIP_OK` when it cannot. Reserve `SKIP` for rows that genuinely need somebody to look at them, and do not use a validation rule as a filter: an excluded row that shows up only as a number going up is indistinguishable, three months later, from a row that failed for a reason nobody noticed.

{% hint style="info" %}
Error rows in the run report are three comma-separated fields — the match value, a status letter, and the error text — and the status letter is a single character: `F` for a failure, `S` for a success. Expect one letter in that column, not the word. Download a report from a run with at least one failure to see the format.
{% endhint %}

## `AttributesScript`

`AttributesScript` is one method:

{% code overflow="wrap" %}

```java
public interface AttributesScript {
    public String[] getAttributes();
}
```

{% endcode %}

This script is instantiated with **nothing injected but `context`** — no `config`, no MQ services, none of the lookup caches a transformation script gets. Write it as a self-contained list. OpenIAM calls `getAttributes()` and asks the connector to retrieve each name it returns. **A null return fails the whole search** with `NullPointerException("attributes is null")` in the synchronization service log, so always return an array even if it is empty.

The field applies only to connector-backed sources. For a `CSV` or `RDBMS` source it is ignored entirely — no error, no warning, and no effect on the run.

This is the list of attribute names the connector is asked to retrieve — get it wrong and the transform script sees a `columnMap` missing keys, with no error anywhere. The shipped `sync/user/ad/ADUserSyncAttributes_1.0.groovy` is the model:

{% code overflow="wrap" %}

```groovy
import org.openiam.sync.service.AttributesScript

class ADUserSyncAttributes implements AttributesScript {
    @Override
    String[] getAttributes() {
        return ["memberOf","members", "cn", "sn", "streetAddress", "l","postalCode","st",
                "givenName", "sAMAccountName", "dn", "DistinguishedName", "mobile", "telephoneNumber","mail",
                "modifyTimestamp", "createTimestamp", "mail", "userPrincipalName"] as String[];
    }
}
```

{% endcode %}

## Pre-processor and post-processor scripts

Both extend the same interface, `SyncAbstractProcessorScript`:

{% code overflow="wrap" %}

```java
public interface SyncAbstractProcessorScript {
    int postAction(SynchConfig config);
    int isValid(SynchConfig config);
}
```

{% endcode %}

`SyncPreprocessorScript` and `SyncPostprocessorScript` are empty marker sub-interfaces. Their return codes are **not** the `TransformScript` set — they are `SyncConstants`:

| Constant                | Value |
| ----------------------- | ----- |
| `SyncConstants.FAIL`    | `0`   |
| `SyncConstants.SUCCESS` | `1`   |
| `SyncConstants.SKIP`    | `2`   |

OpenIAM calls `isValid(config)` on the preprocessor and stops the whole synchronization on `FAIL` or `SKIP`; it calls `postAction(config)` on the postprocessor and does the same. Note the trap: **`FAIL` is `0`**, so a script that returns `0` out of habit — the batch-task convention — aborts the run. Return `SyncConstants.SUCCESS` (`1`).

{% hint style="warning" %}
**The response code for this is misspelled, so searching for the right spelling finds nothing.** A run stopped this way sets `SYNCHRONIZATION_PRE_SRIPT_FAILURE`, or `SYNCHRONIZATION_POST_SRIPT_FAILURE` for the postprocessor — **`SRIPT`, with no `C`**. The typo is the product's, in `ResponseCode`, so that is the string carried wherever the code is surfaced. Where it is surfaced varies, and we have not confirmed which screens display it; search for `SRIPT` rather than `SCRIPT`, and quote it as-is in a support ticket.

The code alone does not say *why* the script failed — only that `isValid` or `postAction` did not return `SUCCESS`. The reason is whatever the script itself logged.
{% endhint %}

Note also that the postprocessor runs *after the run is validated and started*, not after it finishes. Do not use it for end-of-run reporting.

Start from `sync/SamplePreProcessorScript_1.0.groovy` and `SamplePostProcessorScript_1.0.groovy`.

## A complete transform script that starts a new-hire workflow

This is the pattern that makes an HR feed governed rather than automatic: existing people are updated directly; new people become a new-hire request that an approver sees; leavers become a termination request; rows outside scope are skipped as successes.

Adapted from `sync/user/csv/CSVSampleTransformationScript_1.0.groovy` — the class declaration, the base class and the `execute` signature are unchanged from the shipped script.

{% code overflow="wrap" lineNumbers="true" expandable="true" %}

```groovy
import org.openiam.dto.EmailAddress
import org.openiam.dto.MetadataType
import org.openiam.idm.srvc.synch.dto.LineObject
import org.openiam.idm.srvc.user.dto.UserStatusEnum
import org.openiam.provision.dto.ProvisionUser
import org.openiam.provision.type.Attribute
import org.openiam.sync.service.impl.service.AbstractUserTransformScript

/**
 * Governed HR feed:
 *   new worker      -> INITIATE_NEW_HIRE_WORKFLOW (5)  approval creates the user
 *   leaving worker  -> INITIATE_TERMINATE_USER_WORKFLOW (10)
 *   existing worker -> NO_DELETE (0)                   written directly
 *   out of scope    -> SKIP_OK (11)                    skipped, counted as success
 */
class WorkerTransform extends AbstractUserTransformScript {

    /** Only these values of the source COMPANY_CODE column are in scope. */
    static final List<String> IN_SCOPE = ["1000", "1200"]
    static final String SUPERVISOR_TYPE_ID = "DEF_SUPERVISOR_TYPE"
    static final String DEFAULT_ROLE = "End User"

    @Override
    void init() { }

    @Override
    int execute(LineObject rowObj, ProvisionUser pUser) {

        Map<String, Attribute> col = (Map<String, Attribute>) rowObj.columnMap

        String companyCode = value(col, "COMPANY_CODE")
        if (companyCode == null || !IN_SCOPE.contains(companyCode)) {
            // Not our population. SKIP_OK, never SKIP: SKIP is reported as an error.
            return SKIP_OK
        }

        String workerId = value(col, "EMPLOYEE_ID")
        if (workerId == null) {
            // No stable key -> this row must be investigated, so fail it visibly.
            return SKIP
        }

        // ---- attributes that apply to both new and existing people -------------
        pUser.employeeId = workerId
        safeString(col, "FIRST_NAME", { String v -> pUser.firstName = v })
        safeString(col, "LAST_NAME", { String v -> pUser.lastName = v })
        safeString(col, "TITLE", { String v -> pUser.title = v })
        addUserAttribute(pUser, "companyCode", companyCode)

        String hireDate = value(col, "HIRE_DATE")
        if (hireDate != null) {
            pUser.startDate = parseFlexibleDate(hireDate)
        }

        String leaveDate = value(col, "TERMINATION_DATE")
        Date parsedLeaveDate = null
        if (leaveDate != null) {
            parsedLeaveDate = parseFlexibleDate(leaveDate)
            if (isValidExpiry(parsedLeaveDate)) {
                pUser.lastDate = parsedLeaveDate
            }
        }

        String managerId = value(col, "MANAGER_EMPLOYEE_ID")
        if (managerId != null) {
            MetadataType supervisorType = new MetadataType()
            supervisorType.id = SUPERVISOR_TYPE_ID
            addSupervisorToUser(pUser, "employeeid", managerId, supervisorType, config.getManagedSysId())
        }

        // ---- new worker: hand it to the new-hire approval flow -----------------
        if (isNewUser) {
            String email = value(col, "EMAIL")
            if (email != null && !isEmailTaken(email)) {
                EmailAddress e = new EmailAddress()
                e.mdTypeId = "PRIMARY_EMAIL"
                e.emailAddress = email
                e.default = true
                e.active = true
                addUserEmailAddress(pUser, e)
            }

            pUser.mdTypeId = "DEFAULT_USER"
            pUser.status = UserStatusEnum.PENDING_START_DATE
            addUserRoleByName(pUser, DEFAULT_ROLE, null, null, null, null, null)

            String login = value(col, "LOGIN")
            if (login != null && !isLoginTaken(login, config.getManagedSysId(), null)) {
                addLogin(pUser, login, config.getManagedSysId())
            }

            // Valid only because isNewUser is true. Returning this for an existing
            // user is audited as OPERATION_NOT_SUPPORTED and does nothing.
            return INITIATE_NEW_HIRE_WORKFLOW
        }

        // ---- existing worker who is leaving: termination request ---------------
        if (parsedLeaveDate != null && isValidExpiry(parsedLeaveDate) && !parsedLeaveDate.after(new Date())) {
            pUser.accessRightEndDate = parsedLeaveDate
            return INITIATE_TERMINATE_USER_WORKFLOW
        }

        // ---- existing worker, ordinary update ---------------------------------
        return NO_DELETE
    }

    private String value(Map<String, Attribute> col, String key) {
        Attribute a = col.get(key)
        return (a != null && a.value != null && !a.value.trim().isEmpty()) ? a.value.trim() : null
    }
}
```

{% endcode %}

Three things to copy from it:

* **Gate every `INITIATE_*` return on `isNewUser`.** New-hire and add-and-invite need `isNewUser == true`; edit-user, revoke-access, change-position and terminate-user need `isNewUser == false`.
* **`SKIP_OK` for out of scope, `SKIP` for genuinely broken.** The report distinguishes them; the run status depends on it.
* **`parseFlexibleDate` throws.** Either accept that an unparseable date fails the record with `EXCEPTION`, or wrap it and return `SKIP` deliberately.

## Related

* [Edit a synchronization transformation script](/administration-guide/automation/synchronization-scripts.md) — copying, changing, attaching and testing one, and the email and UPN removal worked end to end
* [Set up a synchronization](/administration-guide/lifecycle/synchronize-and-import-data.md) — the source, the match rule and the schedule
* [Create a Groovy script](/administration-guide/automation/manage-groovy-scripts.md) — versioning, promoting and rolling back
* [Shipped Groovy scripts](/administration-guide/reference/shipped-groovy-scripts.md) — the scripts under `sync/`
* [Approval flows](/administration-guide/requests/approval-flows.md) — where an `INITIATE_*` return sends the record, and who approves it
* [Reconciliation scripts](/administration-guide/lifecycle/reconciliation-scripts.md) — the other direction, with a different set of contracts
* [Synchronization configuration fields](/administration-guide/reference/synchronization-configuration-fields.md) — every field on the configuration screen

*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/reference/synchronization-script-contracts.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.
