> For the complete documentation index, see [llms.txt](https://docs.selcier.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.selcier.xyz/fundamentals/compliance-modules.md).

# Compliance modules

A reference for the Rule Engine and each compliance module, covering configuration parameters, returned reason codes, and the way lots are recorded and drawn down.

A `RuleEngine` sits behind every Selcier Share Token. Whenever tokens move, it calls each module enabled for that share class, and all of them must agree before anything settles. A single rejection reverts the transfer with the reason code of the module that refused.

Companies pick and tune modules in the [Console](/the-platform/issuer-console.md). Each module is a contract of its own, which is why a rule can be added, dropped or retuned with no token upgrade.

## Sequence of checks

```
canTransfer(from, to, amount)
  1. Token:            paused?               → TOKEN_PAUSED
                       frozen / locked?      → FROZEN, INSUFFICIENT_UNLOCKED_BALANCE
  2. IdentityRegistry: sender verified?      → SENDER_NOT_VERIFIED, CLAIM_EXPIRED
                       recipient verified?   → RECIPIENT_NOT_VERIFIED, CLAIM_EXPIRED
  3. RuleEngine:       each enabled module, in configured order
  4. OK
```

Whichever check fails first supplies the reason code. Agents are treated differently so that rules bear on the people at each end of a trade: the company's `LiquidityWindow` and any venue with a `VENUE` claim for the token are spared the identity check on their own side, and modules drop their sender-side or recipient-side check for them.

Configuration reaches a module as ABI-encoded bytes, either at share class deployment or afterward through `configureModule`. In the tables below, rows appear in encoding order. Since `canTransfer` is a view function, a wallet, venue or the API can run it ahead of any signature.

## The modules

### Country restrictions: `RegionModule`

This module screens holders on the country named in their `REGION` claim.

| Parameter | Type                                                    | Purpose                                                                                     |
| --------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `mode`    | `uint8`: `0` means `ALLOW_LIST`, `1` means `BLOCK_LIST` | Decides whether `regions` names countries that are permitted or countries that are excluded |
| `regions` | `bytes2[]`                                              | Up to 250 ISO 3166 alpha-2 country codes                                                    |

The check falls on the recipient, and lacking a valid `REGION` claim means being blocked in either mode. The code returned is `REGION_BLOCKED`.

### Investor tiers: `EligibilityModule`

The recipient needs a valid `INVESTOR_ELIGIBLE` claim whose tier meets a configured floor.

| Parameter         | Type    | Purpose                                                                                        |
| ----------------- | ------- | ---------------------------------------------------------------------------------------------- |
| `minimumTier`     | `uint8` | The floor: lower tiers cannot receive tokens                                                   |
| `exemptEmployees` | `bool`  | When on, the tier test is waived for anyone whose valid `EMPLOYEE_OF` claim names this company |

The code returned is `NOT_ELIGIBLE`.

### Lockups: `HoldingPeriodModule`

A lockup starts on each lot at the moment of acquisition.

| Parameter         | Type               | Purpose                                                                                                         |
| ----------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- |
| `holdingPeriod`   | `uint32` (seconds) | The lockup a lot gets unless an override applies                                                                |
| `sources`         | `bytes32[]`        | Acquisition sources that get a lockup of their own, `OFFERING` or `WINDOW` for instance                         |
| `periods`         | `uint32[]`         | Lockups matched by position to `sources`                                                                        |
| `operators`       | `address[]`        | Addresses that count as labelling operators, generally the `OfferingVault` and `LiquidityWindow` of the company |
| `operatorSources` | `bytes32[]`        | The label for each address in `operators`, matched by position                                                  |

Lots from a labelled operator inherit that operator's source. Unlabelled mints count as `ISSUANCE`, and unlabelled transfers as `TRANSFER`. Each kind of override is limited to 16 entries. Because periods come from the live configuration, editing one also affects lots that already exist. If the sender's unlocked lots fall short of the amount, the code returned is `HOLDING_PERIOD_ACTIVE`.

### Holder limit: `HolderCapModule`

This one limits how many distinct holders a share class may have.

| Parameter    | Type     | Purpose                                                      |
| ------------ | -------- | ------------------------------------------------------------ |
| `maxHolders` | `uint32` | The most identities that may hold a non-zero balance at once |

Wallets are irrelevant here, since the count runs on identities. Once the ceiling is hit, a transfer to someone new returns `HOLDER_CAP_REACHED`. Moving tokens between existing holders is never blocked, and neither is a transfer in which the sender exits their entire position.

### Trading periods: `TransferWindowModule`

Transfers are possible only inside periods the company defines.

| Parameter                     | Type                                  | Purpose                                                                                            |
| ----------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `windows`                     | `(uint40 opensAt, uint40 closesAt)[]` | The open periods, 64 at most                                                                       |
| `allowDuringLiquidityWindows` | `bool`                                | When on, anything moving through the company's `LiquidityWindow` gets past this module at any time |
| `liquidityWindow`             | `address`                             | Points the module at the contract the previous flag refers to                                      |

Mints pass regardless. Outside every open period the code returned is `WINDOW_CLOSED`.

### First refusal: `ROFRModule`

The company, or someone it designates, gets first refusal on transfers.

| Parameter      | Type               | Purpose                                                                                                |
| -------------- | ------------------ | ------------------------------------------------------------------------------------------------------ |
| `holder`       | `address`          | Owner of the right, in most cases the company treasury                                                 |
| `noticePeriod` | `uint32` (seconds) | The time the owner of the right has to act once a transfer is proposed                                 |
| `exemptVenues` | `address[]`        | As many as 16 settlement contracts, `LiquidityWindow` among them, where the right is exercised in-line |

The flow for a direct transfer begins on the module:

1. The seller calls `propose(to, amount)`, which emits `TransferProposed`. Until this happens, the transfer returns `ROFR_PENDING`.
2. The code stays `ROFR_PENDING` until the notice period runs out or the owner of the right calls `waive(from, to)`.
3. Should the owner call `exercise(from, to)` instead, that proposal can only result in a transfer to the owner. Payment is left to the two parties, who might use [RFQ](/markets-and-capital/quote-based-trading.md).

Anything going to or coming from the owner of the right passes automatically. Inside a [Liquidity Window](/markets-and-capital/scheduled-windows.md), the same right takes the form of a priority bid at the clearing price.

### Concentration limit: `MaxOwnershipModule`

No single identity may own more than a set portion of the class.

| Parameter          | Type        | Purpose                                                                           |
| ------------------ | ----------- | --------------------------------------------------------------------------------- |
| `maxBps`           | `uint16`    | The largest permitted holding, in basis points of total supply                    |
| `applyToMints`     | `bool`      | Extends the cap to mints. With this off, a cap table can be imported in any order |
| `exemptIdentities` | `address[]` | As many as 32 identities outside the cap, founders or the treasury for example    |

A holding past the cap fails with `MAX_OWNERSHIP_EXCEEDED`.

### Employee vesting: `EmployeeVestingModule`

Shares that an employee has not yet vested stay locked, and they are released on a schedule.

| Parameter | Type    | Purpose                                                                                                                 |
| --------- | ------- | ----------------------------------------------------------------------------------------------------------------------- |
| `action`  | `uint8` | Selects the operation. `0` changes `accelerationEnabled`, and `1` fully vests a single grant                            |
| `payload` | `bytes` | `abi.encode(bool accelerationEnabled)` for action `0`, and `abi.encode(address identity, uint256 index)` for action `1` |

Grants originate in the cap table, which is why the registrar adds them straight to the module through `addGrants(identity, grants)`. An identity can have up to 16, each with a start, cliff, duration and total, and release is linear once the cliff has passed. Acceleration is a configuration change, so it travels through the engine and therefore the `IssuerTimelock`, and it needs `accelerationEnabled` to be set. Tracking per identity means someone with several wallets remains a single holder. A transfer reaching into unvested shares returns `UNVESTED`. In [Portfolio](/the-platform/holder-portfolio.md), vested shares display as available and the remainder as locked.

## How lots work

A **lot** is a block of tokens acquired in one event: a mint at onboarding, say, or an offering allocation or a Liquidity Window purchase. Selcier records holdings this way because lockups and vesting both turn on acquisition date, and every lot keeps its amount, acquisition time and source.

Sending tokens uses lots up **first in, first out**, so the oldest goes first and the next follows. `HoldingPeriodModule` passes a transfer only when each lot being used has finished its lockup. The recipient gets a fresh lot stamped with the current time.

Bear these details in mind:

* Lots belong to wallets, not identities.
* A balance that predates the module is treated as the oldest holding, and it is unlocked.
* A wallet recovery hands lots to the replacement wallet with their timestamps intact.
* Enable the holder cap, ownership cap and vesting modules at share class deployment, ahead of the first mint, because all three keep balances by identity and not by wallet.

```
Holder lots (FIFO)          Transfer 1,500
  Lot 1  1,000  2025-03-01  → consumed in full
  Lot 2  2,000  2026-06-15  → 500 consumed, 1,500 remain
```

To inspect a holder's lots, open Portfolio or call `GET /v1/holders/{id}/lots`.

## Every code, by layer

| Layer             | Codes                                                                                                                                                  |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Success           | `OK`                                                                                                                                                   |
| The token         | `TOKEN_PAUSED`, `FROZEN`, `INSUFFICIENT_UNLOCKED_BALANCE`                                                                                              |
| Identity Registry | `SENDER_NOT_VERIFIED`, `RECIPIENT_NOT_VERIFIED`, `CLAIM_EXPIRED`                                                                                       |
| Modules           | `REGION_BLOCKED`, `NOT_ELIGIBLE`, `HOLDING_PERIOD_ACTIVE`, `HOLDER_CAP_REACHED`, `WINDOW_CLOSED`, `ROFR_PENDING`, `MAX_OWNERSHIP_EXCEEDED`, `UNVESTED` |

The section for each module above names its code, and [Transfer outcome codes](/build-on-selcier/transfer-outcomes.md) explains them all in plain language.

## Changing a module after launch

All module changes, whether adding, removing or reconfiguring, pass through the `IssuerTimelock` and wait at least 24 hours. `ModuleAdded(module)` marks an addition and `ModuleRemoved(module)` a removal, while a reconfiguration emits `ModuleConfigured(module, configHash)`. No share class can exceed 10 modules, and each module has to be bound at creation to the engine of its share class. Current settings for any module are readable on-chain and appear in the [Explorer](/the-platform/public-explorer.md).


---

# 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.selcier.xyz/fundamentals/compliance-modules.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.
