examples/auto-clear-dob-mismatch.json
{
"name": "Auto-clear hits with incompatible name and date of birth",
"description": "Marks a hit as false positive when both the name and the date of birth differ significantly from the applicant's data. ComplyAdvantage, individuals only.",
"condition": {
"or": [
{ "and": [
{ "op": "eq", "args": [ { "exp": "screening.vendor" }, { "lit": "\"complyAdvantageCSOM\"" } ] },
{ "op": "ne", "args": [ { "exp": "input.entityType" }, { "lit": "\"company\"" } ] },
{ "op": "call", "args": [
{ "exp": "namesIncompatible" },
{ "exp": "match" },
{ "exp": "input.fullName" },
{ "exp": "input.country" },
{ "lit": "\"weakContainment\"" },
{ "lit": "false" }
] },
{ "op": "notContainsAny", "args": [ { "exp": "match.complyAdvantage.matchTypes" }, { "lit": "[\"dob_exact\", \"year_of_birth\", \"year_and_month_of_birth\"]" } ] },
{ "op": "eq", "args": [ { "exp": "match.dobs.hasData" }, { "lit": "true" } ] },
{ "op": "call", "args": [
{ "exp": "dobsIncompatible" },
{ "exp": "match.dobs" },
{ "exp": "input.dob" },
{ "lit": "\"weakContainment\"" }
] }
] }
]
},
"review": {
"matchStatus": "false_positive",
"note": "Marked as false positive because both name and date of birth differ significantly."
}
}
examples/minimal.json
{
"name": "Tag PEP-only hits",
"description": "Adds a tag to every hit that carries only the pep risk label, so compliance can filter them in the queue.",
"condition": {
"or": [
{ "and": [
{ "op": "ne", "args": [ { "exp": "input.entityType" }, { "lit": "\"company\"" } ] },
{ "op": "containsOnly", "args": [ { "exp": "match.riskLabels" }, { "lit": "[\"pep\"]" } ] }
] }
]
},
"tags": ["pep_only_hit"]
}
examples/reuse-previous-review.json
{
"name": "Copy decision from previous screening review",
"description": "If the same applicant was screened within the last 24 months, carries over the decision made on the prior check for this match.",
"condition": {
"or": [
{ "and": [
{ "op": "notEmpty", "args": [ { "exp": "match.previousReview" } ] },
{ "op": "lt", "args": [ { "exp": "match.previousReview.modifiedAt.ageInYears" }, { "lit": "2" } ] }
] }
]
},
"review": {
"expression": "match.previousReview"
}
}
references/aml-resolution-rules-schema.md
# AML resolution rules — full schema reference
Field-level reference for the AML Resolution Rules chain (also called the **AML Resolution Rule Chain**): the per-tenant, ordered list of rules that auto-review incoming AML screening hits. Read `SKILL.md` first for the procedure; come here for exact field semantics, enums, and limits.
## Concept & lifecycle
There is **one rule chain per tenant**, versioned by revisions:
| `revisionStatus` | Meaning |
|---|---|
| `draft` | The editable revision. All rule changes (upsert / reorder) apply here. Auto-created on first read or write as a copy of the published revision (or empty if nothing is published). |
| `published` | The revision applied to live AML screening. Read-only — to change it, edit the draft and publish. Exactly one revision is published at a time. |
| `archived` | Previous published revisions. History only. |
Transitions: `draft → published` (publish; the old published revision auto-archives). There is no unpublish — to deactivate rules, publish a draft with the rules removed/changed.
**The chain is shared across live and sandbox** — only the AML cases / applicants are isolated, not the chain. Publishing (even with a sandbox token) makes the chain auto-review **real production** AML hits. Everything before publish touches only the draft, which reviews nothing. Publish only on explicit user request; the backend refuses the publish once the chain is active (too many applicants already processed / integration complete), and the user must then publish from the web UI — see `SKILL.md`.
## Endpoints
All under `https://api.sumsub.com/resources/api/agent/amlResolutionRules`. App Token + HMAC signing (see `SKILL.md` auth section). The token must be **AI-purpose** and carry `manageAmlCases` + `manageClientSettings`.
| Method | Path | Rate limit | Semantics |
|---|---|---|---|
| `GET` | `/draft` | 60/min | Returns the draft (auto-creates from published / empty — never 404s for an enabled tenant). Includes current `notices[]`. |
| `GET` | `/published` | 30/min | The live revision. `404` when nothing has been published yet. |
| `GET` | `/archive[?forwardMarker=...]` | **5/min** | Archived revisions, newest first, 10 per page. Marker-paginated: pass the response's `forwardMarker` back to get the next page; absent marker = last page. Heavily rate-limited — history, not something to poll. |
| `GET` | `/library` | 10/min | Ready-made rule templates grouped by category. |
| `POST` | `/draft/rules` | 60/min | **Upsert one rule.** Omit `id` → create: server assigns the id and appends the rule at the **end** of the evaluation order. Provide an existing `id` → update that rule in place (the request body's fields are written as given; this is a whole-rule replace, not a merge — send the full rule). Response = the full draft with freshly recomputed `notices[]`. |
| `PUT` | `/draft/rules/order` | 60/min | Body `{"ruleIds": [...]}` — a **complete permutation** of every rule id currently in the draft, exactly once each. Partial lists are rejected. `404` when no draft exists. |
| `DELETE` | `/draft/rules/{id}` | 20/min | Remove one rule from the draft by id. **Idempotent** — deleting an id not in the draft leaves the draft unchanged and still succeeds. `404` when no draft exists. Response = the full draft with recomputed `notices[]`. One rule at a time; no bulk delete. The skill gates this behind a consent policy — see `SKILL.md`. |
| `POST` | `/draft/publish` | 5/min | Publishes the draft. Refused while the draft has any `error`-level notice. `404` when no draft exists. **Intended for the integration phase**: once the integration is active, the API may refuse to publish, and the chain must be reviewed and published manually from the Sumsub web UI. |
Errors:
- `404 "API is not available"` on every endpoint → the AML Resolution Rules API is not enabled for this tenant. Stop and tell the user to contact their CSM or Sumsub support.
- `403 "This endpoint is not allowed for this type of token"` → the App Token is not AI-purpose. The user needs to mint an **AI** App Token (AI token page in the dashboard UI).
## Rule chain revision object
```jsonc
{
"id": "6650...", // id of this revision
"revision": 3, // increases with each new revision
"revisionStatus": "draft", // draft | published | archived
"created": {"date": "...", "clientSubject": "..."}, // action trails: who/when
"modified": {"date": "...", "clientSubject": "..."},
"published": {"date": "...", "clientSubject": "..."}, // present once published
"archived": {"date": "...", "clientSubject": "..."}, // present once archived
"rules": [ ... ], // the rules, in evaluation order (max 100)
"notices": [ ... ] // validation findings (max 100)
}
```
## Rule object
```jsonc
{
"id": "...", // unique within the rule chain; omit on create
"name": "...", // required, ≤1000 chars — human-readable rule name
"description": "...", // optional, ≤3000 chars
"condition": { ... }, // Condition AST selecting the hits the rule applies to
"review": { ... }, // review action applied to matched hits
"tags": {"tags": ["..."]} // applicant/case tags added when the rule matches (≤100)
}
```
A rule must have a `review` and/or `tags` action — neither present yields an `emptyAction` error notice.
## Condition AST
OR of ANDs of criteria — the same `Condition` shape workflow edge conditions use, **authored directly** (there is no expression mini-language; the builder validates and passes it through):
```jsonc
{
"or": [ // 1–30 branches; first matching branch wins
{
"and": [ // 1–30 criteria, all must hold
{"op": "eq", "args": [{"exp": "screening.vendor"}, {"lit": "\"complyAdvantageCSOM\""}]}
]
}
]
}
```
Each criterion: `{"op": <operator>, "args": [<arg>, ...]}` with 1–10 args. An arg is either:
- `{"exp": "<dotted path>"}` — an expression path from `aml-rules-context-fields.md` (values for enum-backed fields in `aml-rules-enumerations.md`), ≤512 chars; or
- `{"lit": "<JSON-encoded value>"}` — a literal, ≤8192 chars. **Lit values are JSON-encoded strings**: `"\"USA\""` (string), `"3"` (number), `"true"` (boolean), `"[\"pep\",\"sanctions\"]"` (string array).
**Negation** is expressed with the `not*` operators (`notIn`, `notContains`, `notContainsAny`, `ne`, `notEmpty`, …), not a flag. A top-level `Condition.negate` is **not supported** (UI-unsupported, and the builder rejects it) — matching the workflow skill.
### Operators
| `op` | Args | Meaning |
|---|---|---|
| `eq` / `ne` | exp, value | Equal / not equal (value = lit or another exp) |
| `eqIgnoreCase` / `neIgnoreCase` | exp, value | Case-insensitive equal / not equal |
| `eqOrNull` / `eqIgnoreCaseOrNull` | exp, value | Equal, also true when the field is null |
| `gt` / `gte` / `lt` / `lte` | exp, value | Numeric / date comparison |
| `match` / `notMatch` | exp, lit | Regex match / non-match |
| `in` / `notIn` | exp, lit array | Value is / is not one of the listed values |
| `startsWith` / `notStartsWith` | exp, lit | String prefix |
| `endsWith` / `notEndsWith` | exp, lit | String suffix |
| `empty` / `notEmpty` | exp | Field is absent-or-empty / present |
| `contains` / `notContains` | exp, value | The list *has* / *does not have* this one value. "Hit carries `adverseMedia`" → `contains ["adverseMedia"]`. |
| `containsAny` / `notContainsAny` | exp, lit array | The list has *at least one of* / *none of* these values (set intersection). |
| `containsAll` | exp, lit array | The list has *every* listed value (listed values ⊆ field). |
| `containsOnly` | exp, lit array | The list has *nothing outside* these values (field ⊆ listed values) — i.e. the field's values are **only** these. "Adverse-media-**only** hit" → `containsOnly ["adverseMedia"]`. |
> **Expressing "only X" on a list field** (risk labels, tags, match types). Use `containsOnly ["X"]`. Do **not** approximate it by excluding the other values (`notContainsAny [...]`): that forces you to enumerate the entire remaining vocabulary and silently leaks the moment one value is missed or the vocabulary grows — this is a common, hard-to-spot bug. `containsOnly` is the closed-set complement done correctly (equivalently: the listed values `containsAll` the field). For risk labels the closed set is the six in [`aml-rules-enumerations.md`](aml-rules-enumerations.md#AmlRiskLabel).
| `call` | exp (function name), then function args | Built-in function call — see below |
### Built-in functions (`op: call`)
The first arg is the function name as an `{"exp": ...}`; remaining args follow the function signature (exp paths or JSON-encoded lits):
| Function | Signature | Meaning |
|---|---|---|
| `namesCompatible` | `(match, name, country, comparisonMode, compareAlternativeNames)` | True when the match's name is compatible with `name` (usually `input.fullName`), using `country`'s name-normalization rules. `comparisonMode`: `"strict"` or `"weakContainment"`. `compareAlternativeNames`: boolean — also compare the match's alternative names. |
| `namesIncompatible` | same | Negation of `namesCompatible`. |
| `dobsCompatible` | `(dobs, date, comparisonMode)` | True when any of the match's dates of birth (`match.dobs`) is compatible with `date` (usually `input.dob`). `comparisonMode`: `"strict"` or `"weakContainment"`. |
| `dobsIncompatible` | same | Negation of `dobsCompatible`. |
Example (from the built-in library — auto-clear hits whose name and DoB both diverge):
```json
{"op": "call", "args": [
{"exp": "dobsIncompatible"},
{"exp": "match.dobs"},
{"exp": "input.dob"},
{"lit": "\"weakContainment\""}
]}
```
A misspelled function name produces an `unknownFunction` error notice on the draft.
## Review action
Two mutually exclusive forms:
**Per-field form** — each field is an arg (`{"lit": ...}` or `{"exp": ...}`); only the fields present are set on the hit's review:
```jsonc
{
"matchStatus": {"lit": "\"false_positive\""}, // see enum below
"riskLevel": {"lit": "\"LOW\""}, // vendor-specific, see below
"whitelisted": {"lit": "true"}, // boolean
"note": {"lit": "\"Cleared automatically: DoB mismatch\""}
}
```
**Whole-review form** — `expression` evaluates to an entire review object, which is copied onto the hit verbatim; the per-field keys are ignored when `expression` is set:
```jsonc
{"expression": "match.previousReview"} // carry over the previous screening's decision
```
> The OpenAPI description of `expression` ("condition guarding the review action") is misleading — the implemented semantics are the whole-review form above.
### `matchStatus` values
| Value | Meaning |
|---|---|
| `unknown` | No answer — the hit is still to be reviewed. |
| `potential_match` | The hit potentially identifies the same entity as the screening input. |
| `false_positive` | The hit most likely identifies a *different* entity. |
| `true_positive` | The hit identifies the same entity. |
(`no_match` exists but is deprecated — use `false_positive`.)
A hit that receives a non-null `matchStatus` from a rule counts as **reviewed** — later rules in the chain no longer evaluate against it.
### `riskLevel` values
Vendor-specific, not a fixed enum. For World-Check One: `LOW`, `MEDIUM`, `HIGH`, `UNKNOWN`. Possible values may change with vendor changes; when unsure, omit `riskLevel`.
### `whitelisted`
Client-side risk acceptance: allows approving the applicant even when the hit is `potential_match` / `true_positive`.
## Tags action
```jsonc
{"tags": {"tags": ["match_tin"]}}
```
Adds the listed tags to the AML case (deduplicated). These are **applicant tags** — the common, tenant-wide tag list used across the whole platform (dashboard filters, workflows, other rules), not something private to AML screening.
Tags do **not** need to pre-exist: upserting a rule automatically adds any unknown tag from its `tags` action to the tenant's applicant tag list. That makes introducing a tag a **tenant-wide side effect** — so prefer reusing an existing tag when one fits, and give new tags clear, readable, reusable, concise names (`youngPep`, `match_tin` — not `rule3_tag` or a sentence). The `invalidTag` error notice fires for tags referenced in **conditions** (`screening.tags` literals) that exist nowhere — typically a typo, or a condition checking a tag no rule or human ever sets.
Tags added by one rule are visible to **later** rules in the same run via `screening.tags`, which enables chaining (e.g. rule 5 tags `match_tin`; rule 9 marks all remaining hits `potential_match` when `screening.tags containsAny ["match_tin"]`).
Tags also cross over into **applicant workflows**: workflow edge conditions see them as `checks.personWatchlist.tags` (individuals) and `checks.companyWatchlist.tags` (companies). A rule chain can therefore encode screening expertise as a tag (`youngPep` when `match.riskLabels contains pep` and the applicant is under 30) and a workflow routes on it (`checks.personWatchlist.tags contains youngPep` → manual review). See the workflow skill's expressions reference for the workflow-side paths.
## Notices
Validation findings recomputed on every draft change. `severity`: `info` | `warning` | `error` — **any `error` blocks publishing**.
| `message` key | Meaning |
|---|---|
| `emptyRules` | The rule chain has no rules. |
| `emptyAction` | A rule has neither `review` nor `tags`. |
| `emptyTags` | A `tags` action with an empty list. |
| `invalidTag` | Tag `{tag}` referenced in a condition (`screening.tags`) does not exist anywhere — typically a typo. Tags in a rule's `tags` *action* are auto-created on upsert and don't trigger this. |
| `unknownField` | Expression path `{field}` does not exist in the context. |
| `unknownValue` | Value `{value}` is not valid for field `{field}` (vocabulary-backed fields). |
| `compareWithEmptyList` | Field `{field}` is compared to an empty list. |
| `emptyFunctionName` | A `call` criterion without a function name. |
| `unknownFunction` | Function `{function}` is not a known built-in. |
| `error` | Generic validation error `{error}`. |
`messageParams` carries the `{placeholders}`; `ruleId` points at the offending rule when applicable.
## Library
`GET /library` returns `{"categories": [{"id", "name", "rules": [{"vendors": [...], "rule": {...}}]}]}`. Library rules are **templates**: take the `rule` definition, adjust it, strip the `id`, and upsert it into the draft (the server assigns a fresh id). The `vendors` list names the AML screening vendors the template is designed for (empty = applies to all) — check it against the vendor the tenant uses before suggesting a template.
## How the rule chain executes (what to tell users)
When an AML screening produces hits, the **published** rule chain runs:
1. Rules run **in order, top to bottom**. Order matters.
2. Each rule is evaluated against **every hit that is not yet reviewed** (no `matchStatus` from moderation or an earlier rule).
3. When a rule's condition is true for a hit: the review action is applied to that hit, the tags are added to the case, and the case-level context (`screening.answer`, `screening.tags`, `screening.matchStatuses`) is **recomputed immediately** — later rules see the effects of earlier ones.
4. The run stops early once the case is fully resolved.
Consequences for rule design:
- Put **specific, decisive** rules (exact-ID matches, carry-over of previous reviews) **before** broad clean-up rules.
- A broad `false_positive` rule placed first can "steal" hits from a more precise `true_positive` rule placed after it.
- Chained rules (via `screening.tags` / `screening.matchStatuses`) only see effects of rules **above** them.
references/aml-rules-context-fields.md
## Types
### <a name="AmlRuleExpressionContext"></a>`Root variables`
| Field | Type | Description |
| --- | --- | --- |
| <a name="AmlRuleExpressionContext.applicant"></a>`applicant` | `Object` | Applicant information |
| <a name="AmlRuleExpressionContext.applicantIdDoc"></a>`applicantIdDoc` | `Object` | |
| <a name="AmlRuleExpressionContext.input"></a>`input` | [`Object`](#ScreeningInputExpressionData) | Aml case input information |
| <a name="AmlRuleExpressionContext.screening"></a>`screening` | [`Object`](#AmlCaseExpressionData) | Aml case information |
| <a name="AmlRuleExpressionContext.match"></a>`match` | [`Object`](#HitExpressionData) | Current hit information |
### <a name="ScreeningInputExpressionData"></a>`input`
| Field | Type | Description |
| --- | --- | --- |
| <a name="ScreeningInputExpressionData.firstName"></a>`firstName` | `String` | First name |
| <a name="ScreeningInputExpressionData.firstNameEn"></a>`firstNameEn` | `String` | Transliterated first name |
| <a name="ScreeningInputExpressionData.lastName"></a>`lastName` | `String` | Last name |
| <a name="ScreeningInputExpressionData.lastNameEn"></a>`lastNameEn` | `String` | Transliterated last name |
| <a name="ScreeningInputExpressionData.middleName"></a>`middleName` | `String` | Middle name |
| <a name="ScreeningInputExpressionData.middleNameEn"></a>`middleNameEn` | `String` | Transliterated middle name |
| <a name="ScreeningInputExpressionData.fullName"></a>`fullName` | `String` | Full name |
| <a name="ScreeningInputExpressionData.aliasName"></a>`aliasName` | `String` | Alias of the screened entity. For French passports, it's the value of the "epouse" field. |
| <a name="ScreeningInputExpressionData.gender"></a>`gender` | `String` | Gender (M or F) |
| <a name="ScreeningInputExpressionData.dob"></a>`dob` | `Date` | Date of birth |
| <a name="ScreeningInputExpressionData.country"></a>`country` | `String` | Country (ISO 3166-1 alpha-3) |
| <a name="ScreeningInputExpressionData.town"></a>`town` | `String` | Town if applicable. |
| <a name="ScreeningInputExpressionData.state"></a>`state` | `String` | State name or region if applicable. |
| <a name="ScreeningInputExpressionData.companyInfo"></a>`companyInfo` | [`Object`](#CompanyInputExpressionData) | Company information |
| <a name="ScreeningInputExpressionData.countries"></a>`countries` | List of `String`-s | Countries (ISO 3166-1 alpha-3) |
| <a name="ScreeningInputExpressionData.entityType"></a>`entityType` | `String` | input entity type: individual or company |
| <a name="ScreeningInputExpressionData.countryOfBirth"></a>`countryOfBirth` | `String` | Country of birth (ISO 3166-1 alpha-3) |
| <a name="ScreeningInputExpressionData.nationality"></a>`nationality` | `String` | Nationality (ISO 3166-1 alpha-3) |
| <a name="ScreeningInputExpressionData.analysis"></a>`analysis` | [`Object`](#ScreeningInputAnalysisExpressionData) | Some signals based on applicant input data |
### <a name="AmlCaseExpressionData"></a>`screening`
| Field | Type | Description |
| --- | --- | --- |
| <a name="AmlCaseExpressionData.id"></a>`id` | `String` | Aml case ID |
| <a name="AmlCaseExpressionData.createdAt"></a>`createdAt` | `Date` | Aml case creation date |
| <a name="AmlCaseExpressionData.targetEntityType"></a>`targetEntityType` | [`Enum`](#TargetEntityType) | Target entity type: applicant or transactionParticipant |
| <a name="AmlCaseExpressionData.vendor"></a>`vendor` | [`Enum`](#VendorType) | Aml screening vendor: ComplyAdvantage, etc. |
| <a name="AmlCaseExpressionData.hitsCount"></a>`hitsCount` | `int` | Matches count |
| <a name="AmlCaseExpressionData.riskLabels"></a>`riskLabels` | List of `String`-s | Risk labels collected from all matches in case |
| <a name="AmlCaseExpressionData.matchStatuses"></a>`matchStatuses` | List of `String`-s | Matches match statuses collected from all matches in case |
| <a name="AmlCaseExpressionData.answer"></a>`answer` | `String` | The GREEN/YELLOW/RED/ERROR answer of this check. |
| <a name="AmlCaseExpressionData.tags"></a>`tags` | List of `String`-s | AML case tags. One AML rule can add tag, another rule can use it in its condition. All AML tags stored in AML case. |
### <a name="HitExpressionData"></a>`match`
| Field | Type | Description |
| --- | --- | --- |
| <a name="HitExpressionData.name"></a>`name` | `String` | Match name |
| <a name="HitExpressionData.alternativeNames"></a>`alternativeNames` | List of `String`-s | Match alternative names |
| <a name="HitExpressionData.dobs"></a>`dobs` | [`Object`](#HitDobsExpressionData) | Match list of DoB: may be full DoB, may be only year of birth |
| <a name="HitExpressionData.gender"></a>`gender` | `String` | Gender of the matched entity. Null if not available or ambiguous. |
| <a name="HitExpressionData.riskLabels"></a>`riskLabels` | List of `String`-s | Aml case risk labels |
| <a name="HitExpressionData.previousReview"></a>`previousReview` | [`Object`](#HitReviewExpressionData) | |
| <a name="HitExpressionData.complyAdvantage"></a>`complyAdvantage` | [`Object`](#HitComplyAdvantageInfoExpressionData) | ComplyAdvantage-specific match information. |
| <a name="HitExpressionData.complyAdvantageMesh"></a>`complyAdvantageMesh` | [`Object`](#HitComplyAdvantageMeshInfoExpressionData) | ComplyAdvantage-specific match information. |
| <a name="HitExpressionData.worldCheckOne"></a>`worldCheckOne` | [`Object`](#HitWorldCheckInfoExpressionData) | World Check One -specific match information. |
| <a name="HitExpressionData.quantifind"></a>`quantifind` | [`Object`](#HitQuantifindInfoExpressionData) | Quantifind-specific match information. |
| <a name="HitExpressionData.analysis"></a>`analysis` | [`Object`](#HitAnalysisExpressionData) | Some signals based on match data |
| <a name="HitExpressionData.sources"></a>`sources` | [`Object`](#HitSourcesExpressionData) | Some signals based on match sources |
| <a name="HitExpressionData.countries"></a>`countries` | [`Object`](#HitCountriesExpressionData) | Countries related to this match |
### <a name="CompanyInputExpressionData"></a>`input.companyInfo`
| Field | Type | Description |
| --- | --- | --- |
| <a name="CompanyInputExpressionData.name"></a>`name` | `String` | Company name |
| <a name="CompanyInputExpressionData.alternativeNames"></a>`alternativeNames` | List of `String`-s | Alternative names |
### <a name="ScreeningInputAnalysisExpressionData"></a>`input.analysis`
| Field | Type | Description |
| --- | --- | --- |
| <a name="ScreeningInputAnalysisExpressionData.parentName"></a>`parentName` | `String` | Parent name extracted from input data |
| <a name="ScreeningInputAnalysisExpressionData.namesCount"></a>`namesCount` | `int` | Words count or hieroglyphs count in case applicant's name consist of hieroglyphs |
### <a name="TargetEntityType"></a>`screening.targetEntityType`
TargetEntityType specifies type of screened entity: applicant or transaction participant
| Value | Description |
| --- | --- |
| `applicant` | |
| `transactionParticipant` | |
### <a name="VendorType"></a>`screening.vendor`
| Value | Description |
| --- | --- |
| `complyAdvantageCSOM` | Comply Advantage CSOM AML vendor |
| `worldCheckOne` | World Check One AML vendor |
| `quantifind` | Quantifind Graphyte AML vendor |
| `complyAdvantageMesh` | Comply Advantage Mesh AML vendor |
### <a name="HitDobsExpressionData"></a>`match.dobs`
| Field | Type | Description |
| --- | --- | --- |
| <a name="HitDobsExpressionData.hasData"></a>`hasData` | `boolean` | |
### <a name="HitReviewExpressionData"></a>`match.previousReview`
| Field | Type | Description |
| --- | --- | --- |
| <a name="HitReviewExpressionData.matchStatus"></a>`matchStatus` | `String` | |
| <a name="HitReviewExpressionData.riskLevel"></a>`riskLevel` | `String` | |
| <a name="HitReviewExpressionData.whitelisted"></a>`whitelisted` | `Boolean` | |
| <a name="HitReviewExpressionData.modifiedAt"></a>`modifiedAt` | `Date` | Date of last review modification. |
| <a name="HitReviewExpressionData.note"></a>`note` | `String` | Freeform note left by a reviewer. |
### <a name="HitComplyAdvantageInfoExpressionData"></a>`match.complyAdvantage`
| Field | Type | Description |
| --- | --- | --- |
| <a name="HitComplyAdvantageInfoExpressionData.warningTypes"></a>`warningTypes` | List of `String`-s | Types like 'pep', 'sanctions', etc - vendor specific |
| <a name="HitComplyAdvantageInfoExpressionData.matchTypes"></a>`matchTypes` | List of `String`-s | Match type looks like 'name_exact' or 'aka_fuzzy'; allowed values in [aml-rules-enumerations.md](aml-rules-enumerations.md#MatchType). |
### <a name="HitComplyAdvantageMeshInfoExpressionData"></a>`match.complyAdvantageMesh`
| Field | Type | Description |
| --- | --- | --- |
| <a name="HitComplyAdvantageMeshInfoExpressionData.warningTypes"></a>`warningTypes` | List of `String`-s | Types like 'pep', 'sanctions', etc - vendor specific |
| <a name="HitComplyAdvantageMeshInfoExpressionData.matchTypes"></a>`matchTypes` | List of `String`-s | Match type looks like 'name_exact' or 'aka_fuzzy'; allowed values in [aml-rules-enumerations.md](aml-rules-enumerations.md#MatchType). |
### <a name="HitWorldCheckInfoExpressionData"></a>`match.worldCheckOne`
| Field | Type | Description |
| --- | --- | --- |
| <a name="HitWorldCheckInfoExpressionData.warningTypes"></a>`warningTypes` | List of `String`-s | Types like 'pep', 'sanctions', etc - vendor specific |
| <a name="HitWorldCheckInfoExpressionData.resolutionDate"></a>`resolutionDate` | `Date` | Date hit was resolved on WC1's side |
| <a name="HitWorldCheckInfoExpressionData.matchStatus"></a>`matchStatus` | `String` | Match status based on resolution toolkit |
| <a name="HitWorldCheckInfoExpressionData.riskLevel"></a>`riskLevel` | `String` | Risk level based on resolution toolkit |
### <a name="HitQuantifindInfoExpressionData"></a>`match.quantifind`
| Field | Type | Description |
| --- | --- | --- |
| <a name="HitQuantifindInfoExpressionData.riskLevel"></a>`riskLevel` | `String` | Risk level. |
| <a name="HitQuantifindInfoExpressionData.confidence"></a>`confidence` | `String` | Quantifind assessed Confidence Level of the match; values of “Strong”, “Fair”, Weak” |
### <a name="HitAnalysisExpressionData"></a>`match.analysis`
| Field | Type | Description |
| --- | --- | --- |
| <a name="HitAnalysisExpressionData.parentName"></a>`parentName` | `String` | Parent name extracted from match data |
| <a name="HitAnalysisExpressionData.tinCompatible"></a>`tinCompatible` | `Boolean` | true if national id or TIN of the applicant matches nationalId/TIN of the match |
| <a name="HitAnalysisExpressionData.namesCount"></a>`namesCount` | `int` | Words count or hieroglyphs count in case applicant's name consist of hieroglyphs |
| <a name="HitAnalysisExpressionData.pep"></a>`pep` | [`Object`](#PepAnalysisExpressionData) | Politically exposed person's match data analysis |
| <a name="HitAnalysisExpressionData.company"></a>`company` | [`Object`](#CompanyAnalysisExpressionData) | Company match data analysis (computed only when the screened entity is a company) |
### <a name="HitSourcesExpressionData"></a>`match.sources`
| Field | Type | Description |
| --- | --- | --- |
| <a name="HitSourcesExpressionData.earliestStartDate"></a>`earliestStartDate` | `Date` | Null if all source's startDate is null, otherwise the min(startDates). |
| <a name="HitSourcesExpressionData.latestEndDate"></a>`latestEndDate` | `Date` | Null if all source's endDate is null, otherwise the max(endDates) |
### <a name="HitCountriesExpressionData"></a>`match.countries`
Holds ISO 3166-1 alpha-3 country codes extracted from a watchlist hit, grouped by the role
each country plays for the sanctioned entity. Used as an expression context variable in AML rules.
| Field | Type | Description |
| --- | --- | --- |
| <a name="HitCountriesExpressionData.all"></a>`all` | List of `String`-s | Deduplicated union of all country codes across every role. |
| <a name="HitCountriesExpressionData.location"></a>`location` | List of `String`-s | Countries associated with the entity's physical location. |
| <a name="HitCountriesExpressionData.placeOfBirth"></a>`placeOfBirth` | List of `String`-s | Countries associated with the entity's place of birth. |
| <a name="HitCountriesExpressionData.placeOfDeath"></a>`placeOfDeath` | List of `String`-s | Countries associated with the entity's place of death. |
| <a name="HitCountriesExpressionData.operatesIn"></a>`operatesIn` | List of `String`-s | Countries in which the entity operates. |
| <a name="HitCountriesExpressionData.registeredIn"></a>`registeredIn` | List of `String`-s | Countries in which the entity is registered. |
| <a name="HitCountriesExpressionData.vesselFlag"></a>`vesselFlag` | List of `String`-s | Flag countries for vessels associated with the entity. |
### <a name="PepAnalysisExpressionData"></a>`match.analysis.pep`
| Field | Type | Description |
| --- | --- | --- |
| <a name="PepAnalysisExpressionData.institutionKinds"></a>`institutionKinds` | List of `String`-s | Kinds of political institutions encountered in the match data. |
| <a name="PepAnalysisExpressionData.institutionJurisdictions"></a>`institutionJurisdictions` | List of `String`-s | Jurisdiction types of political institutions encountered in the match data. |
| <a name="PepAnalysisExpressionData.institutionRegionVsInputComparisons"></a>`institutionRegionVsInputComparisons` | List of `String`-s | Comparisons between screened person's address and the regions associated with political institutions present in the match data. |
| <a name="PepAnalysisExpressionData.declassificationDate"></a>`declassificationDate` | `Date` | Date when, according to the match data, either the person vacated the political position or this position stopped to be recognized as political due to a regulatory change. <br> When multiple positions: - If any doesn't specify an end date, declassificationDate is none. - If all specify an end date, declassificationDate is the most recent one. |
| <a name="PepAnalysisExpressionData.associationEndDate"></a>`associationEndDate` | `Date` | Date when, according to the match data, the association between the person and a PEP ceased. |
### <a name="CompanyAnalysisExpressionData"></a>`match.analysis.company`
Company-specific match analysis exposed to AML rules as `match.analysis.company`.
Currently carries only `nameCompatibility` — how well the screened company name resembles
the watchlist-hit name (country- and legal-form-aware), used to suppress false positives.
| Field | Type | Description |
| --- | --- | --- |
| <a name="CompanyAnalysisExpressionData.nameCompatibility"></a>`nameCompatibility` | `String` | Strongest name-compatibility confidence level across every screened-name vs hit-name pair (high > medium > low > negligible. notApplicable result means one of the input names is either empty or written in an unsupported script. |
## Index
- [`applicant`](#AmlRuleExpressionContext.applicant)
- [`applicantIdDoc`](#AmlRuleExpressionContext.applicantIdDoc)
- [`input`](#AmlRuleExpressionContext.input).[`analysis`](#ScreeningInputExpressionData.analysis).[`namesCount`](#ScreeningInputAnalysisExpressionData.namesCount)
- [`input`](#AmlRuleExpressionContext.input).[`analysis`](#ScreeningInputExpressionData.analysis).[`parentName`](#ScreeningInputAnalysisExpressionData.parentName)
- [`input`](#AmlRuleExpressionContext.input).[`nationality`](#ScreeningInputExpressionData.nationality)
- [`input`](#AmlRuleExpressionContext.input).[`countryOfBirth`](#ScreeningInputExpressionData.countryOfBirth)
- [`input`](#AmlRuleExpressionContext.input).[`entityType`](#ScreeningInputExpressionData.entityType)
- [`input`](#AmlRuleExpressionContext.input).[`countries`](#ScreeningInputExpressionData.countries)
- [`input`](#AmlRuleExpressionContext.input).[`companyInfo`](#ScreeningInputExpressionData.companyInfo).[`alternativeNames`](#CompanyInputExpressionData.alternativeNames)
- [`input`](#AmlRuleExpressionContext.input).[`companyInfo`](#ScreeningInputExpressionData.companyInfo).[`name`](#CompanyInputExpressionData.name)
- [`input`](#AmlRuleExpressionContext.input).[`state`](#ScreeningInputExpressionData.state)
- [`input`](#AmlRuleExpressionContext.input).[`town`](#ScreeningInputExpressionData.town)
- [`input`](#AmlRuleExpressionContext.input).[`country`](#ScreeningInputExpressionData.country)
- [`input`](#AmlRuleExpressionContext.input).[`dob`](#ScreeningInputExpressionData.dob)
- [`input`](#AmlRuleExpressionContext.input).[`gender`](#ScreeningInputExpressionData.gender)
- [`input`](#AmlRuleExpressionContext.input).[`aliasName`](#ScreeningInputExpressionData.aliasName)
- [`input`](#AmlRuleExpressionContext.input).[`fullName`](#ScreeningInputExpressionData.fullName)
- [`input`](#AmlRuleExpressionContext.input).[`middleNameEn`](#ScreeningInputExpressionData.middleNameEn)
- [`input`](#AmlRuleExpressionContext.input).[`middleName`](#ScreeningInputExpressionData.middleName)
- [`input`](#AmlRuleExpressionContext.input).[`lastNameEn`](#ScreeningInputExpressionData.lastNameEn)
- [`input`](#AmlRuleExpressionContext.input).[`lastName`](#ScreeningInputExpressionData.lastName)
- [`input`](#AmlRuleExpressionContext.input).[`firstNameEn`](#ScreeningInputExpressionData.firstNameEn)
- [`input`](#AmlRuleExpressionContext.input).[`firstName`](#ScreeningInputExpressionData.firstName)
- [`screening`](#AmlRuleExpressionContext.screening).[`tags`](#AmlCaseExpressionData.tags)
- [`screening`](#AmlRuleExpressionContext.screening).[`answer`](#AmlCaseExpressionData.answer)
- [`screening`](#AmlRuleExpressionContext.screening).[`matchStatuses`](#AmlCaseExpressionData.matchStatuses)
- [`screening`](#AmlRuleExpressionContext.screening).[`riskLabels`](#AmlCaseExpressionData.riskLabels)
- [`screening`](#AmlRuleExpressionContext.screening).[`hitsCount`](#AmlCaseExpressionData.hitsCount)
- [`screening`](#AmlRuleExpressionContext.screening).[`vendor`](#AmlCaseExpressionData.vendor)
- [`screening`](#AmlRuleExpressionContext.screening).[`targetEntityType`](#AmlCaseExpressionData.targetEntityType)
- [`screening`](#AmlRuleExpressionContext.screening).[`createdAt`](#AmlCaseExpressionData.createdAt)
- [`screening`](#AmlRuleExpressionContext.screening).[`id`](#AmlCaseExpressionData.id)
- [`match`](#AmlRuleExpressionContext.match).[`countries`](#HitExpressionData.countries).[`vesselFlag`](#HitCountriesExpressionData.vesselFlag)
- [`match`](#AmlRuleExpressionContext.match).[`countries`](#HitExpressionData.countries).[`registeredIn`](#HitCountriesExpressionData.registeredIn)
- [`match`](#AmlRuleExpressionContext.match).[`countries`](#HitExpressionData.countries).[`operatesIn`](#HitCountriesExpressionData.operatesIn)
- [`match`](#AmlRuleExpressionContext.match).[`countries`](#HitExpressionData.countries).[`placeOfDeath`](#HitCountriesExpressionData.placeOfDeath)
- [`match`](#AmlRuleExpressionContext.match).[`countries`](#HitExpressionData.countries).[`placeOfBirth`](#HitCountriesExpressionData.placeOfBirth)
- [`match`](#AmlRuleExpressionContext.match).[`countries`](#HitExpressionData.countries).[`location`](#HitCountriesExpressionData.location)
- [`match`](#AmlRuleExpressionContext.match).[`countries`](#HitExpressionData.countries).[`all`](#HitCountriesExpressionData.all)
- [`match`](#AmlRuleExpressionContext.match).[`sources`](#HitExpressionData.sources).[`latestEndDate`](#HitSourcesExpressionData.latestEndDate)
- [`match`](#AmlRuleExpressionContext.match).[`sources`](#HitExpressionData.sources).[`earliestStartDate`](#HitSourcesExpressionData.earliestStartDate)
- [`match`](#AmlRuleExpressionContext.match).[`analysis`](#HitExpressionData.analysis).[`company`](#HitAnalysisExpressionData.company).[`nameCompatibility`](#CompanyAnalysisExpressionData.nameCompatibility)
- [`match`](#AmlRuleExpressionContext.match).[`analysis`](#HitExpressionData.analysis).[`pep`](#HitAnalysisExpressionData.pep).[`associationEndDate`](#PepAnalysisExpressionData.associationEndDate)
- [`match`](#AmlRuleExpressionContext.match).[`analysis`](#HitExpressionData.analysis).[`pep`](#HitAnalysisExpressionData.pep).[`declassificationDate`](#PepAnalysisExpressionData.declassificationDate)
- [`match`](#AmlRuleExpressionContext.match).[`analysis`](#HitExpressionData.analysis).[`pep`](#HitAnalysisExpressionData.pep).[`institutionRegionVsInputComparisons`](#PepAnalysisExpressionData.institutionRegionVsInputComparisons)
- [`match`](#AmlRuleExpressionContext.match).[`analysis`](#HitExpressionData.analysis).[`pep`](#HitAnalysisExpressionData.pep).[`institutionJurisdictions`](#PepAnalysisExpressionData.institutionJurisdictions)
- [`match`](#AmlRuleExpressionContext.match).[`analysis`](#HitExpressionData.analysis).[`pep`](#HitAnalysisExpressionData.pep).[`institutionKinds`](#PepAnalysisExpressionData.institutionKinds)
- [`match`](#AmlRuleExpressionContext.match).[`analysis`](#HitExpressionData.analysis).[`namesCount`](#HitAnalysisExpressionData.namesCount)
- [`match`](#AmlRuleExpressionContext.match).[`analysis`](#HitExpressionData.analysis).[`tinCompatible`](#HitAnalysisExpressionData.tinCompatible)
- [`match`](#AmlRuleExpressionContext.match).[`analysis`](#HitExpressionData.analysis).[`parentName`](#HitAnalysisExpressionData.parentName)
- [`match`](#AmlRuleExpressionContext.match).[`quantifind`](#HitExpressionData.quantifind).[`confidence`](#HitQuantifindInfoExpressionData.confidence)
- [`match`](#AmlRuleExpressionContext.match).[`quantifind`](#HitExpressionData.quantifind).[`riskLevel`](#HitQuantifindInfoExpressionData.riskLevel)
- [`match`](#AmlRuleExpressionContext.match).[`worldCheckOne`](#HitExpressionData.worldCheckOne).[`riskLevel`](#HitWorldCheckInfoExpressionData.riskLevel)
- [`match`](#AmlRuleExpressionContext.match).[`worldCheckOne`](#HitExpressionData.worldCheckOne).[`matchStatus`](#HitWorldCheckInfoExpressionData.matchStatus)
- [`match`](#AmlRuleExpressionContext.match).[`worldCheckOne`](#HitExpressionData.worldCheckOne).[`resolutionDate`](#HitWorldCheckInfoExpressionData.resolutionDate)
- [`match`](#AmlRuleExpressionContext.match).[`worldCheckOne`](#HitExpressionData.worldCheckOne).[`warningTypes`](#HitWorldCheckInfoExpressionData.warningTypes)
- [`match`](#AmlRuleExpressionContext.match).[`complyAdvantageMesh`](#HitExpressionData.complyAdvantageMesh).[`matchTypes`](#HitComplyAdvantageMeshInfoExpressionData.matchTypes)
- [`match`](#AmlRuleExpressionContext.match).[`complyAdvantageMesh`](#HitExpressionData.complyAdvantageMesh).[`warningTypes`](#HitComplyAdvantageMeshInfoExpressionData.warningTypes)
- [`match`](#AmlRuleExpressionContext.match).[`complyAdvantage`](#HitExpressionData.complyAdvantage).[`matchTypes`](#HitComplyAdvantageInfoExpressionData.matchTypes)
- [`match`](#AmlRuleExpressionContext.match).[`complyAdvantage`](#HitExpressionData.complyAdvantage).[`warningTypes`](#HitComplyAdvantageInfoExpressionData.warningTypes)
- [`match`](#AmlRuleExpressionContext.match).[`previousReview`](#HitExpressionData.previousReview).[`note`](#HitReviewExpressionData.note)
- [`match`](#AmlRuleExpressionContext.match).[`previousReview`](#HitExpressionData.previousReview).[`modifiedAt`](#HitReviewExpressionData.modifiedAt)
- [`match`](#AmlRuleExpressionContext.match).[`previousReview`](#HitExpressionData.previousReview).[`whitelisted`](#HitReviewExpressionData.whitelisted)
- [`match`](#AmlRuleExpressionContext.match).[`previousReview`](#HitExpressionData.previousReview).[`riskLevel`](#HitReviewExpressionData.riskLevel)
- [`match`](#AmlRuleExpressionContext.match).[`previousReview`](#HitExpressionData.previousReview).[`matchStatus`](#HitReviewExpressionData.matchStatus)
- [`match`](#AmlRuleExpressionContext.match).[`riskLabels`](#HitExpressionData.riskLabels)
- [`match`](#AmlRuleExpressionContext.match).[`gender`](#HitExpressionData.gender)
- [`match`](#AmlRuleExpressionContext.match).[`dobs`](#HitExpressionData.dobs).[`hasData`](#HitDobsExpressionData.hasData)
- [`match`](#AmlRuleExpressionContext.match).[`alternativeNames`](#HitExpressionData.alternativeNames)
- [`match`](#AmlRuleExpressionContext.match).[`name`](#HitExpressionData.name)
## Functions
| Function | Return type | Description |
| --- | --- | --- |
| ``namesCompatible(HitExpressionData match, String name, String country, String comparisonMode, Boolean compareAlternativeNames)`` | boolean | Returns <code>true</code>true - if match name equals to the specified name Calculates if match name and the specified name are compatible. The specified country and settings are used to compare names.<br/> Parameters: <ul><li>``HitExpressionData match`` - - the match data to compare</li><li>``String name`` - - the name to compare with</li><li>``String country`` - - the specified country's normalization rules are used comparing names</li><li>``String comparisonMode`` - - Name comparison mode: strict or weakContainment.</li><li>``Boolean compareAlternativeNames`` - - Flag indicating if alternative names of the match will be taken into account.</li></ul> |
| ``namesIncompatible(HitExpressionData match, String name, String country, String comparisonMode, Boolean compareAlternativeNames)`` | boolean | Returns <code>true</code>true - if match name equals to the specified name Calculates if match name and the specified name are NOT compatible. The specified country and settings are used to compare names.<br/> Parameters: <ul><li>``HitExpressionData match`` - - the match data to compare</li><li>``String name`` - - the name to compare with</li><li>``String country`` - - the specified country's normalization rules are used comparing names</li><li>``String comparisonMode`` - - Name comparison mode: strict or weakContainment.</li><li>``Boolean compareAlternativeNames`` - - Flag indicating if alternative names of the match will be taken into account.</li></ul> |
| ``dobsCompatible(HitDobsExpressionData dobs, DateExpressionData date, String comparisonMode)`` | boolean | Returns <code>true</code>true - if match any dates of birth equals to the specified name Calculates if match any dates of birth and the specified date are compatible. The specified country and settings are used to compare names.<br/> Parameters: <ul><li>``HitDobsExpressionData dobs`` - - the match dates of birth</li><li>``DateExpressionData date`` - - the date to compare with</li><li>``String comparisonMode`` - - Dob comparison mode: strict or weakContainment.</li></ul> |
| ``dobsIncompatible(HitDobsExpressionData dobs, DateExpressionData date, String comparisonMode)`` | boolean | Returns <code>true</code>true - if match any dates of birth equals to the specified name Calculates if match any dates of birth and the specified date are NOT compatible. The specified country and settings are used to compare names.<br/> Parameters: <ul><li>``HitDobsExpressionData dobs`` - - the match dates of birth</li><li>``DateExpressionData date`` - - the date to compare with</li><li>``String comparisonMode`` - - Dob comparison mode: strict or weakContainment.</li></ul> |
references/aml-rules-enumerations.md
# AML resolution rules — enumerations reference
Allowed values for the AML rule-context fields that are **closed enums** — fields that show as plain `String` / `List of String`
in [`aml-rules-context-fields.md`](aml-rules-context-fields.md) but only ever hold one of a fixed set of values.
Check here for the valid values (and what they mean) before comparing against such a field. A few entries have
no description yet — the value itself is still authoritative.
> A field typed `List of String` here carries a **subset** of these values. To match "only value X" use `containsOnly ["X"]` (never `notContainsAny [the-others]` — see the operator note in [`aml-resolution-rules-schema.md`](aml-resolution-rules-schema.md)).
### <a name="InputEntityType"></a>`InputEntityType`
Used by: `input.entityType`
| Value | Description |
|--------------|----------------------------------------------------------------------------------------------------|
| `unknown` | The screening input's entity type is unknown at the time of the screening. |
| `individual` | Screened entity is an individual. |
| `company` | Screened entity is a legal entity, such as a company, corporation, organization or an institution. |
### <a name="CompanyNameConfidenceLevel"></a>`CompanyNameConfidenceLevel`
Used by: `match.analysis.company.nameCompatibility`
| Value | Description |
|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|
| `high` | names very likely the same organization |
| `medium` | important differences but cannot be ruled out: e.g. a clearly different legal form, or one side adds only common/low-significance tokens to a shared core |
| `low` | a significant token matches but each side has its own distinct significant tokens (a remote partial match) |
| `negligible` | no significant shared token; almost zero chance of the same org |
| `notApplicable` | nothing comparable: blank/placeholder, every token stripped as a legal form, or a script we cannot read. |
### <a name="InstitutionJurisdiction"></a>`InstitutionJurisdiction`
Used by: `match.analysis.pep.institutionJurisdictions`
| Value | Description |
|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `international` | Jurisdiction spans multiple sovereign states; the institution is not part of any single national government (e.g. UN, EU institutions, IMF, World Bank, African Union, ICC). |
| `national` | Country-wide / sovereign-state level. In federations this includes the federal government. |
| `subnational` | First-level country subdivision with its own elected or appointed government — state, province, region, oblast, prefecture, federative unit, autonomous community, krai, voivodeship, federal subject. |
| `local` | Sub-state or municipal level — county, district, city, town, commune, municipality, borough, parish, raion. |
| `extraterritorial` | Institution belongs to country A but is physically seated and operates in country B. Typically used for diplomatic missions abroad and military bases abroad. |
| `unknown` | Jurisdiction cannot be determined from the provided sources. |
### <a name="InstitutionKind"></a>`InstitutionKind`
Used by: `match.analysis.pep.institutionKinds`
| Value | Description |
|-----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `legislature` | Law-making body at any territorial level — national parliament or congress, regional or state legislature, city or county council with legislative power. |
| `executiveBody` | Any executive-branch body regardless of seniority — head of state or government office, cabinet, regional or local executive, ministry, department, executive agency, or public agency that is not an independent regulator. |
| `regulatoryAgency` | Independent regulator with statutory authority — election commission, anti-corruption agency, competition / financial / data-protection regulator. |
| `court` | Judicial body — constitutional, supreme, appellate, federal, regional, local, specialised tribunal, or military court. |
| `prosecutorOffice` | Public prosecution authority, procuracy, or attorney general's office. |
| `auditInstitution` | Supreme audit body, court of accounts, or national audit office. |
| `lawEnforcement` | Police, gendarmerie, or other uniformed service with a policing function (non-military). |
| `armedForces` | Military body — army, navy, air force, joint command, or branch of the armed forces. |
| `intelligenceAgency` | Civilian or military intelligence or security service. |
| `diplomaticMission` | Embassy, consulate, permanent mission to an international organisation, or ambassador-at-large posting. |
| `internationalOrganization` | Inter-governmental organisation and its organs (UN, EU, IMF, World Bank, African Union, OAS, ASEAN, etc.). |
| `centralBank` | Central or reserve bank, or other monetary authority. |
| `stateOwnedEnterprise` | Corporate entity owned or controlled by the state at any level. Includes boards of state-controlled companies. |
| `politicalParty` | Political party leadership organ or parliamentary party. In one-party systems, the party's executive committee. |
| `monarchy` | Reigning royal house — monarchs, regents, or royal family members acting in a head-of-state capacity. |
| `religiousAuthority` | Head or senior office-holder of a state-recognised religious institution where religion has a constitutional role. |
| `electoralBody` | Election commission, electoral tribunal, or supreme electoral court. |
| `other` | The institution does not fit any specific kind above, or the role description is too generic to classify (e.g. 'Senior Government Official' or 'Politician' without further context). |
### <a name="InstitutionRegionVsInputComparison"></a>`InstitutionRegionVsInputComparison`
Used by: `match.analysis.pep.institutionRegionVsInputComparisons`
| Value | Description |
|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `same` | Same country and same first-level subdivision between the screened person's address and the institution's region. |
| `differentRegionWithinSameCountry` | Same country, but different first-level subdivision. Both regions must be independently identified in the data for this verdict. |
| `differentCountry` | The screened person's address and the institution are in different countries. |
| `indeterminable` | The comparison cannot be made — either side lacks a clearly identified first-level subdivision, the institution operates across multiple countries, or the regional signals in the match data conflict. |
### <a name="MatchType"></a>`MatchType`
Used by: `match.complyAdvantage.matchTypes`, `match.complyAdvantageMesh.matchTypes`
| Value | Description |
|---------------------------|---------------------------------------------------------------------------------------|
| `unknown` | Matched for a more complex reason, such as based on an acronym. |
| `name_exact` | Matched against the entity name exactly. |
| `name_compatible` | |
| `equivalent_name` | Matched against the entity name with a synonym, e.g. 'Robert Mugabe' => 'Bob Mugabe'. |
| `name_synonym` | |
| `aka_exact` | Matched against an entity AKA (also known as) entry exactly. |
| `aka_synonym` | |
| `aka_phonetic` | |
| `fuzzy` | |
| `name_fuzzy` | Matched closely to the name, but at least one word had an edit-distance change. |
| `dob_exact` | |
| `year_of_birth` | Matched the birth year as given in filters; can be the exact year or ±1 year. |
| `year_and_month_of_birth` | |
| `address_exact` | |
| `address_partial` | |
| `phonetic_name` | Matched against the entity name phonetically. |
| `phonetic_aka` | Matched against an entity AKA phonetically. |
| `aka_fuzzy` | Matched closely to an AKA name, but at least one word had an edit-distance change. |
| `name_variations_removal` | |
| `removed_personal_suffix` | A personal suffix, for example 'PhD', was stripped from the search term. |
| `removed_personal_title` | A personal title, for example 'Mrs', was stripped from the search term. |
### <a name="ConfidenceLevel"></a>`ConfidenceLevel`
Used by: `match.quantifind.confidence`
| Value | Description |
|----------|------------------------------------------------------------|
| `Strong` | Quantifind-assessed Confidence Level of the match: Strong. |
| `Fair` | Quantifind-assessed Confidence Level of the match: Fair. |
| `Weak` | Quantifind-assessed Confidence Level of the match: Weak. |
### <a name="QuantifindRiskLevel"></a>`QuantifindRiskLevel`
Used by: `match.quantifind.riskLevel`
| Value | Description |
|-----------|-------------|
| `high` | High |
| `medium` | Medium |
| `neutral` | Neutral |
| `noRisk` | NoRisk |
### <a name="ImageTestAnswer"></a>`ImageTestAnswer`
Used by: `screening.answer`
| Value | Description |
|-----------|----------------------------------------------------------------------------------|
| `IGNORED` | Check was skipped. |
| `GREEN` | Everything is fine. |
| `YELLOW` | Fine with some warnings / but most likely undefined (requires attention, review) |
| `RED` | Some violations found. |
| `ERROR` | Error occurred. |
### <a name="WatchlistMatchStatus"></a>`WatchlistMatchStatus`
Used by: `screening.matchStatuses`, `match.previousReview.matchStatus`, `match.worldCheckOne.matchStatus`
| Value | Description |
|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------|
| `unknown` | There is no answer yet, the hit is still to be reviewed or the review is now irrelevant due to the presence of other reviewed matches. |
| `potential_match` | AML hit (match) potentially identifies the same person as specified by the screening input. |
| `false_positive` | AML hit (match) is most likely identifying an entity that is different to one specified by the screening input. |
| `true_positive` | AML hit (match) identifies the same person as specified by the screening input. |
### <a name="AmlRiskLabel"></a>`AmlRiskLabel`
Used by: `screening.riskLabels`, `match.riskLabels`
| Value | Description |
|------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `pep` | The screened entity belongs to the PEP (Politically Exposed Person) category. |
| `sanctions` | The screened entity was found in sanctions lists. |
| `terrorism` | The screened entity is suspected of terrorism. |
| `crime` | The screened entity is suspected of criminal activity. |
| `adverseMedia` | The compromising published information that is related to the user was found in the media. |
| `fitnessProbity` | The screened entity does not comply with Fitness and Probity regime. The core function of the Fitness and Probity Regime is to ensure that individuals in key and customer facing positions are competent and capable |
### <a name="NameComparisonMode"></a>`NameComparisonMode`
Used by: the `comparisonMode` argument of the `namesCompatible` / `namesIncompatible` functions (`call`).
| Value | Description |
|-------------------|-------------|
| `strict` | |
| `weakContainment` | |
| `def` | |
| `ai` | |
### <a name="DobComparisonMode"></a>`DobComparisonMode`
Used by: the `comparisonMode` argument of the `dobsCompatible` / `dobsIncompatible` functions (`call`).
| Value | Description |
|-------------------|-------------|
| `strict` | |
| `weakContainment` | |
| `def` | |
references/aml-rules-expressions.md
# AML resolution rules expression reference
The inventory of every legal `exp` path, field type, built-in function, and enum value usable in AML resolution rule conditions and review actions, generated from Sumsub's `AmlRuleExpressionContext` schema. Open it whenever a rule needs an `exp` path (or an allowed value) you can't quote from memory, or to verify one before POSTing.
**This reference is split into three files** (this one is the index):
1. **This file** — preamble: how to use the reference, root variables, the shared `applicant` / `applicantIdDoc` and `Date` shapes, and the enum caveat below.
2. [**`aml-rules-context-fields.md`**](aml-rules-context-fields.md) — every legal dotted `exp` path with its type (`## Types` / `## Index` / `## Functions`).
3. [**`aml-rules-enumerations.md`**](aml-rules-enumerations.md) — the allowed **values** for fields that are closed enums (see the caveat below).
> **⚠️ Enum caveat — many `String` / `List of String` fields are actually closed enums.** In the context-fields doc, most enum-backed fields (risk labels, match statuses, PEP institution kinds, match types, quantifind levels, entity type, …) appear only as plain `String`, without their allowed values. Those vocabularies live in [`aml-rules-enumerations.md`](aml-rules-enumerations.md) — **check there for the valid values before comparing against such a field**, and remember a closed set means "only X" is `containsOnly ["X"]`, never `notContainsAny [the-others]`. (`TargetEntityType` and `VendorType` are tabled inline in the context-fields doc; the rest are in the enumerations file.)
**Efficient access — don't read end-to-end; this is a lookup database.**
- **Validate a path:** `grep '<leafFieldName>' aml-rules-context-fields.md`. The leaf field name (the last component of the path) appears verbatim in both the Types row and the Index entry. Searching for a full dotted path like `match.analysis.tinCompatible` returns no matches — the Index renders each segment as a markdown link so the dotted form is not a contiguous substring.
- **Find a field's allowed values:** search [`aml-rules-enumerations.md`](aml-rules-enumerations.md) — each enum lists the `exp` paths that use it under **Used by**, so you can go from a field to its vocabulary and back.
- **Explore a namespace:** open [`aml-rules-context-fields.md`](aml-rules-context-fields.md) and jump to the `### <TypeName>` section directly. Anchors are stable (`#ScreeningInputExpressionData`, `#AmlCaseExpressionData`, `#HitExpressionData`, …).
**Root variables.** `applicant` (the applicant being screened), `applicantIdDoc` (their identity document), `input` (the data sent to the AML vendor for screening), `screening` (the AML case as a whole), `match` (the current hit under evaluation — rule conditions run once per unreviewed hit).
**`applicant` and `applicantIdDoc` paths.** The generated tables type them as plain `Object`: they reuse the applicant / POI document shapes shared with workflow expressions. For their field lists, see [`../../sumsub-create-workflow/references/workflow-expressions.md`](../../sumsub-create-workflow/references/workflow-expressions.md) — the `applicant` (`#ApplicantExpressionData`) and `poi` (`#PoiExpressionData`) types are the same shapes.
**`Date` fields.** Every field typed `Date` (e.g. `input.dob`, `match.previousReview.modifiedAt`, `match.sources.latestEndDate`) exposes these sub-fields:
| Field | Type | Description |
| --- | --- | --- |
| `timestamp` | `long` | Unix timestamp in milliseconds |
| `yyyymmdd` | `int` | Numeric day in YYYYMMDD format, e.g. 20230123 |
| `year` | `int` | Year |
| `month` | `int` | Month of year (starts from 1) |
| `dayOfMonth` | `int` | Day of month (starts from 1) |
| `ageInYears` | `int` | Time delta in years between this date and now |
| `ageInDays` | `int` | Time delta in days between this date and now |
So `match.sources.latestEndDate.ageInDays > 366` means "the latest source end date is more than a year ago", and `match.previousReview.modifiedAt.ageInYears < 2` means "the previous review is less than two years old".
**Out of scope here.** These files list paths, types, functions, and enum values only. Operators (`eq`, `in`, `containsAny`, `call`, …) and the condition AST shape are covered in [`aml-resolution-rules-schema.md`](aml-resolution-rules-schema.md) and the `SKILL.md` operator table.
scripts/aml_rules_api.py
#!/usr/bin/env python3
"""Signed Sumsub AML Resolution Rules API client — one CLI for every network step.
Subcommands (each signs with App Token + secret, HMAC-SHA256):
draft Get the draft revision of the rule chain (the
editable one; auto-created on first access).
Carries the current `notices[]` — read after
every change.
published Get the live (published) revision (404 if none).
archive [forwardMarker] List archived revisions (10/page, newest first).
Pass the previous response's forwardMarker for
the next page. Heavily rate-limited (5/min).
library Get the ready-made rule templates.
upsert [file|-] Create/update one rule from a built payload
(POST /draft/rules). Omit `id` in the payload to
create; include it to replace that rule.
reorder [file|-] Set the evaluation order: body {"ruleIds":[...]}
— a complete permutation of every draft rule id.
delete <ruleId> Remove one rule from the draft by id (idempotent).
See the consent policy in SKILL.md before deleting
a rule you didn't create.
publish Publish the draft (POST /draft/publish). Makes it
the live rule chain; the prior published revision
auto-archives. Refused while the draft has any
error-level notice. Only on explicit user request.
⚠️ publish affects LIVE production behavior. The AML Resolution Rule Chain is
SHARED across live and sandbox — only the AML cases/applicants are isolated — so
publishing with a sandbox token still makes the chain auto-review REAL production
AML screening hits (clearing false positives, confirming true positives,
whitelisting). The backend may REFUSE the publish once the chain is active (too
many applicants already processed / the integration is complete) — that is
expected, not an error to work around: report it and tell the user to publish from
the Sumsub web UI.
Auth & environment (shared by all subcommands):
SUMSUB_APP_TOKEN sandbox App Token ('sbx:' prefix), and AI-purpose — these are
agent endpoints. Non-sandbox tokens are refused unless
SUMSUB_ALLOW_PROD=1.
SUMSUB_SECRET_KEY paired secret.
SUMSUB_BASE API base; default https://api.sumsub.com. Override only for
local/dev testing.
Failure modes worth recognizing:
• 404 "API is not available" -> the AML Resolution Rules API is not enabled for
this tenant. Stop; contact CSM/Sumsub support. Do not retry other endpoints.
• 403 "This endpoint is not allowed for this type of token" -> the App Token is
not AI-purpose. Mint an AI token in the dashboard.
• publish 4xx when the chain is active -> publishing via the API is refused once
there are already too many applicants / the integration is complete; the chain
must then be reviewed and published manually from the Sumsub web UI.
Output: each subcommand prints the response body, then a final `HTTP <code>` line.
"""
import hashlib
import hmac
import json
import os
import re
import subprocess
import sys
import time
from typing import NoReturn
from urllib.parse import quote, urlencode
AML_PATH = "/resources/api/agent/amlResolutionRules"
def die(msg, code) -> NoReturn:
print(msg, file=sys.stderr)
sys.exit(code)
def env():
token = os.environ.get("SUMSUB_APP_TOKEN")
secret = os.environ.get("SUMSUB_SECRET_KEY")
if not token:
die("error: SUMSUB_APP_TOKEN is required (sandbox AI App Token, 'sbx:' prefix)", 2)
if not secret:
die("error: SUMSUB_SECRET_KEY is required (paired secret key)", 2)
if not token.startswith("sbx:") and os.environ.get("SUMSUB_ALLOW_PROD") != "1":
die("error: SUMSUB_APP_TOKEN does not look like a sandbox token (expected 'sbx:' "
"prefix).\n Production credentials must not be shared with this skill.", 3)
base = os.environ.get("SUMSUB_BASE", "https://api.sumsub.com").rstrip("/")
return token, secret, base
def request(method, path, body: "bytes | str" = b""):
"""Signed request via curl. Returns (http_status:int, body:str). status 0 = network error.
Signature payload is ts + method + path + body (the form the API expects).
The HTTP transport is `curl` (not urllib) so the request goes through the same
binary the test harness intercepts; signing stays in Python."""
token, secret, base = ENV
if isinstance(body, str):
body = body.encode()
ts = str(int(time.time()))
sig = hmac.new(secret.encode(), ts.encode() + method.encode() + path.encode() + body,
hashlib.sha256).hexdigest()
headers = {
"X-App-Token": token,
"X-App-Access-Ts": ts,
"X-App-Access-Sig": sig,
"X-Agent-Source": "sumsub-skills",
"X-Agent-Source-Ver": "1.6.0",
"Accept": "application/json",
}
# `-w '\nHTTP %{http_code}\n'` appends the status as a trailing line so we can
# read it back off stdout (curl's exit code alone doesn't carry 4xx/5xx).
cmd = ["curl", "--compressed", "-sS", "-X", method, base + path, "-w", "\nHTTP %{http_code}\n"]
for k, v in headers.items():
cmd += ["-H", "{}: {}".format(k, v)]
if method not in ("GET", "DELETE"):
cmd += ["-H", "Content-Type: application/json",
"--data-binary", body.decode("utf-8", "replace")]
try:
proc = subprocess.run(cmd, capture_output=True, text=True)
except FileNotFoundError:
return 0, "network error: curl not found on PATH"
out = proc.stdout
m = re.search(r"\nHTTP (\d+)\n?\Z", out)
if not m:
return 0, "network error: {}".format(
(proc.stderr or out).strip() or "no HTTP status line from curl")
code = int(m.group(1))
if code == 0: # curl emitted %{http_code}=000 — connection never completed
return 0, "network error: {}".format(
(proc.stderr or "").strip() or "curl reported HTTP 000")
return code, out[:m.start()]
def emit(code, body):
"""Print response body then the trailing `HTTP <code>` line.
Exits non-zero on a transport failure (code 0); HTTP error statuses (4xx/5xx)
are left for the caller to read off the `HTTP <code>` line."""
sys.stdout.write(body)
if not body.endswith("\n"):
sys.stdout.write("\n")
print("HTTP {}".format(code))
if code == 0:
sys.exit(1)
def read_payload(arg):
if arg in (None, "-"):
data = sys.stdin.buffer.read()
elif os.path.isfile(arg):
with open(arg, "rb") as f:
data = f.read()
else:
die("payload file not found: {}".format(arg), 2)
if not data.strip():
die("error: payload is empty (no file content, or stdin closed without data)", 2)
return data
# ── subcommands ───────────────────────────────────────────────────────────────
def cmd_draft(args):
emit(*request("GET", AML_PATH + "/draft"))
def cmd_published(args):
emit(*request("GET", AML_PATH + "/published"))
def cmd_archive(args):
path = AML_PATH + "/archive"
if args.forward_marker:
path += "?" + urlencode({"forwardMarker": args.forward_marker})
emit(*request("GET", path))
def cmd_library(args):
emit(*request("GET", AML_PATH + "/library"))
def cmd_upsert(args):
payload = read_payload(args.file)
emit(*request("POST", AML_PATH + "/draft/rules", payload))
def cmd_reorder(args):
payload = read_payload(args.file)
try:
json.loads(payload)
except ValueError as e:
die("error: payload is not valid JSON: {}".format(e), 2)
emit(*request("PUT", AML_PATH + "/draft/rules/order", payload))
def cmd_delete(args):
# Idempotent: deleting a rule not in the draft still succeeds.
emit(*request("DELETE", "{}/draft/rules/{}".format(AML_PATH, quote(args.rule_id, safe=""))))
def cmd_publish(args):
# The backend enforces publish safety (it refuses once the chain is active /
# too many applicants have been processed), so this is a thin POST; the agent
# surfaces a refusal and points the user to the web UI. Only run on explicit
# user request (see SKILL.md).
emit(*request("POST", AML_PATH + "/draft/publish"))
def main():
import argparse
p = argparse.ArgumentParser(prog="aml_rules_api.py",
description="Signed Sumsub AML Resolution Rules API client.")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("draft", help="get the draft revision (with notices)").set_defaults(fn=cmd_draft)
sub.add_parser("published", help="get the live revision (404 if none)").set_defaults(fn=cmd_published)
s = sub.add_parser("archive", help="list archived revisions (paginated, 5/min)")
s.add_argument("forward_marker", nargs="?", default=None, help="marker from the previous page")
s.set_defaults(fn=cmd_archive)
sub.add_parser("library", help="get the ready-made rule templates").set_defaults(fn=cmd_library)
s = sub.add_parser("upsert", help="create/update one rule from a built payload")
s.add_argument("file", nargs="?", default="-", help="rule payload file, or '-'/omitted for stdin")
s.set_defaults(fn=cmd_upsert)
s = sub.add_parser("reorder", help="set evaluation order: {\"ruleIds\":[...]} (full permutation)")
s.add_argument("file", nargs="?", default="-", help="payload file, or '-'/omitted for stdin")
s.set_defaults(fn=cmd_reorder)
s = sub.add_parser("delete", help="remove one rule from the draft by id (idempotent)")
s.add_argument("rule_id")
s.set_defaults(fn=cmd_delete)
sub.add_parser("publish", help="publish the draft (live; only on explicit request)").set_defaults(fn=cmd_publish)
args = p.parse_args()
global ENV
ENV = env()
args.fn(args)
if __name__ == "__main__":
main()
scripts/build_aml_rule.py
#!/usr/bin/env python3
"""
Assemble an AML resolution rule spec (JSON on stdin) into a full rule payload for
`POST /resources/api/agent/amlResolutionRules/draft/rules` (JSON on stdout).
This is a thin assembler + validator, NOT a translator. The rule `condition` is
the real Sumsub Condition AST, authored directly — the SAME shape as workflow
edge conditions (`{or:[{and:[{op, args:[{exp},{lit}]}]}]}`), where:
- `{exp: "<raw path>"}` is an expression path, verbatim (incl. bracket paths).
- `{lit: "<json>"}` is a literal, given **already JSON-encoded as a string**
the way the API stores it ("false_positive" ->
"\"false_positive\"", 2 -> "2", ["pep"] -> "[\"pep\"]").
The API rejects an un-encoded literal, so the encoding
is mandatory.
AML conditions may call a built-in function via the `call` op: the first arg is
the function name as `{exp: "namesIncompatible"}`, the rest are its arguments
(exp paths or JSON-encoded lits). This is the one operator workflows don't expose.
What the assembler does (and only this):
- Passes the `condition` AST through verbatim; validates every `op` against the
known operator set, rejects a top-level `Condition.negate`, caps `lit` length.
- Assembles the review action (`matchStatus`/`riskLevel`/`whitelisted`/`note` as
Criterion.Args, or the whole-review `expression` form) and the tags action,
with a scalar convenience (a bare review value becomes a JSON-encoded `{lit}`);
validates the `matchStatus` enum and the review/tags shape.
- Enforces that a rule has a review and/or tags action (the `emptyAction` guard).
"""
import json
import sys
MATCH_STATUSES = {"unknown", "potential_match", "false_positive", "true_positive"}
# Operator set mirrors the server's condition operator set. Unlike the workflow
# builder, `call` is allowed — AML rules invoke built-in functions
# (namesCompatible / dobsIncompatible / …) through it.
OPERATORS = {
"eq", "eqIgnoreCase", "neIgnoreCase", "eqOrNull", "eqIgnoreCaseOrNull", "ne",
"lt", "lte", "gt", "gte", "match", "notMatch", "in", "notIn",
"startsWith", "notStartsWith", "endsWith", "notEndsWith", "empty", "notEmpty",
"contains", "notContains", "containsAny", "notContainsAny", "containsAll",
"containsOnly", "call",
}
# AST limits enforced server-side (Condition / Criterion).
MAX_OR_BRANCHES = 30
MAX_AND_CRITERIA = 30
MAX_ARGS = 10
MAX_EXP_LEN = 512
MAX_LIT_LEN = 8192
# ---------- condition validation (pass-through, not transform) ----------------
def _validate_condition(cond) -> None:
"""Walk a hand-authored Condition AST and validate it without transforming it:
- reject any `op` not in the known operator set (catches typos),
- reject a top-level `Condition.negate` — negate via the not* operators
(notContains, notIn, notEmpty, ne, …) instead, as the UI does,
- enforce the server-side size limits (branches / criteria / args / lengths).
`lit` values are passed through verbatim (the author supplies them already
JSON-encoded, the way the API stores them)."""
if not isinstance(cond, dict):
raise ValueError("condition must be the Condition AST object {or:[{and:[...]}]}")
if cond.get("negate"):
raise ValueError("top-level condition.negate is not supported; use a not* operator instead")
branches = cond.get("or")
if not isinstance(branches, list) or not branches:
raise ValueError("condition must have a non-empty 'or' list of AND-branches")
if len(branches) > MAX_OR_BRANCHES:
raise ValueError(f"condition has {len(branches)} OR branches; max {MAX_OR_BRANCHES}")
for branch in branches:
if not isinstance(branch, dict):
raise ValueError(f"each 'or' entry must be an object with an 'and' list; got {branch!r}")
criteria = branch.get("and")
if not isinstance(criteria, list) or not criteria:
raise ValueError("each OR branch must have a non-empty 'and' list of criteria")
if len(criteria) > MAX_AND_CRITERIA:
raise ValueError(f"an OR branch has {len(criteria)} criteria; max {MAX_AND_CRITERIA}")
for crit in criteria:
if not isinstance(crit, dict):
raise ValueError(f"each criterion must be an object {{op, args}}; got {crit!r}")
op = crit.get("op")
if op not in OPERATORS:
raise ValueError(f"unknown operator {op!r}; allowed: {sorted(OPERATORS)}")
args = crit.get("args") or []
if len(args) > MAX_ARGS:
raise ValueError(f"criterion {op!r} has {len(args)} args; max {MAX_ARGS}")
for arg in args:
if not isinstance(arg, dict):
raise ValueError(f"each arg must be {{exp: ...}} or {{lit: ...}}; got {arg!r}")
exp, lit = arg.get("exp"), arg.get("lit")
if exp is not None and len(str(exp)) > MAX_EXP_LEN:
raise ValueError(f"exp longer than {MAX_EXP_LEN} chars: {str(exp)[:60]!r}…")
if lit is not None and isinstance(lit, str) and len(lit) > MAX_LIT_LEN:
raise ValueError(f"lit longer than {MAX_LIT_LEN} chars")
# ---------- review / tags assembly --------------------------------------------
def _to_arg(value, field: str) -> dict:
"""Review-field value → Criterion.Arg. A scalar becomes a JSON-encoded `{lit}`
(the convenience layer); an explicit `{exp:...}`/`{lit:...}` passes through."""
if isinstance(value, dict):
if set(value) == {"exp"} or set(value) == {"lit"}:
return value
raise ValueError(f"review.{field} object must be exactly {{\"exp\": ...}} or {{\"lit\": ...}}; got {value!r}")
if isinstance(value, bool):
return {"lit": "true" if value else "false"}
if isinstance(value, (int, float)):
return {"lit": json.dumps(value)}
if isinstance(value, str):
return {"lit": json.dumps(value)}
raise ValueError(f"review.{field} must be a string, bool, number, or {{exp|lit}} object; got {value!r}")
def _build_review(review: dict) -> dict:
field_keys = {"matchStatus", "riskLevel", "whitelisted", "note"}
unknown = set(review) - field_keys - {"expression"}
if unknown:
raise ValueError(f"unknown review keys: {sorted(unknown)}; allowed: expression, {sorted(field_keys)}")
if review.get("expression") is not None:
if any(review.get(k) is not None for k in field_keys):
raise ValueError(
"review.expression is the whole-review form (e.g. 'match.previousReview') and replaces "
"matchStatus/riskLevel/whitelisted/note entirely — set either expression OR the per-field keys, not both")
return {"expression": str(review["expression"])}
out = {}
for key in ("matchStatus", "riskLevel", "whitelisted", "note"):
if review.get(key) is None:
continue
arg = _to_arg(review[key], key)
if key == "matchStatus" and "lit" in arg:
literal = json.loads(arg["lit"]) if arg["lit"].startswith('"') else arg["lit"]
if literal not in MATCH_STATUSES:
raise ValueError(
f"review.matchStatus {literal!r} is not a valid match status; "
f"allowed: {sorted(MATCH_STATUSES)}")
if key == "whitelisted" and "lit" in arg and arg["lit"] not in ("true", "false"):
raise ValueError(f"review.whitelisted must be a boolean; got {review[key]!r}")
out[key] = arg
if not out:
raise ValueError("review must set expression or at least one of matchStatus/riskLevel/whitelisted/note")
return out
# ---------- top-level ----------------------------------------------------------
def build_rule(spec: dict) -> dict:
name = spec.get("name")
if not name:
raise ValueError("rule spec must have 'name' (the human-readable rule name)")
if len(name) > 1000:
raise ValueError("'name' is longer than 1000 chars")
rule = {"name": name}
if spec.get("id") is not None:
rule["id"] = spec["id"]
description = spec.get("description")
if description is not None:
if len(description) > 3000:
raise ValueError("'description' is longer than 3000 chars")
rule["description"] = description
# Guard the removed authoring shapes so old specs fail loudly, not silently.
if "conditionRaw" in spec:
raise ValueError(
"'conditionRaw' is no longer a separate key — write the Condition AST directly "
"under 'condition' (the expression mini-language was removed). See SKILL.md.")
cond = spec.get("condition")
if cond is None:
raise ValueError("rule spec must have 'condition' (the Condition AST {or:[{and:[...]}]})")
if isinstance(cond, str):
raise ValueError(
"'condition' must be the Condition AST object, not a string — the expression "
"mini-language ('screening.vendor = …', 'fn(...)') was removed. Write the AST "
"directly, e.g. {\"or\":[{\"and\":[{\"op\":\"eq\",\"args\":[{\"exp\":\"screening.vendor\"},"
"{\"lit\":\"\\\"complyAdvantageCSOM\\\"\"}]}]}]}. See SKILL.md.")
_validate_condition(cond)
rule["condition"] = cond
review = spec.get("review")
if review is not None:
if not isinstance(review, dict):
raise ValueError("'review' must be an object")
rule["review"] = _build_review(review)
tags = spec.get("tags")
if tags is not None:
tags = tags if isinstance(tags, list) else [tags]
if not tags:
raise ValueError("'tags' must be a non-empty list when present")
if len(tags) > 100:
raise ValueError(f"'tags' has {len(tags)} entries; max 100")
rule["tags"] = {"tags": [str(t) for t in tags]}
if review is None and tags is None:
raise ValueError(
"rule must have a 'review' action and/or 'tags' — a rule with neither fails "
"validation with an emptyAction notice")
# Pass-through unknown keys (escape hatch for future fields).
handled = {"id", "name", "description", "condition", "review", "tags"}
for k, v in spec.items():
if k in handled or v is None:
continue
rule[k] = v
return rule
def main():
try:
spec = json.load(sys.stdin)
except ValueError as e:
print(f"error: invalid JSON on stdin: {e}", file=sys.stderr)
sys.exit(2)
try:
payload = build_rule(spec)
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
sys.exit(2)
json.dump(payload, sys.stdout, indent=2)
sys.stdout.write("\n")
if __name__ == "__main__":
main()
SKILL.md
---
name: sumsub-create-aml-resolution-rules
description: Create, edit, reorder, delete, and publish Sumsub AML Resolution Rules (the AML Resolution Rule Chain) that auto-review AML screening hits. TRIGGER when the user wants to auto-clear false positives, auto-confirm true positives, carry over previous AML reviews, tag AML hits, or set up / inspect / publish the AML rule chain. SKIP for transaction-monitoring (KYT) rules, workflow routing, or AML check settings on a level (separate skills cover those).
allowed-tools: Read, Write, Bash
---
# Sumsub — Create AML Resolution Rules
Builds AML resolution rules from a compact spec, upserts them into the tenant's **draft** AML rule chain via the agent API, and (optionally) publishes the draft to make it live. The chain (also called the **AML Resolution Rule Chain**) auto-reviews incoming AML screening hits: each rule, in order, is evaluated against every not-yet-reviewed hit, and on match sets the hit's review (match status / risk level / whitelisted / note) and/or tags the case.
Every network step goes through one signed client, `${CLAUDE_SKILL_DIR}/scripts/aml_rules_api.py` (`draft`, `published`, `archive`, `library`, `upsert`, `reorder`, `delete`, `publish`); rule payloads are assembled by `${CLAUDE_SKILL_DIR}/scripts/build_aml_rule.py`.
## Endpoints
All under `/resources/api/agent/amlResolutionRules`. There is **one rule chain per tenant**, versioned as `draft → published → archived` revisions. (Each row maps to an `aml_rules_api.py` subcommand — you never call these paths by hand.)
| Method | Path | Subcommand | When |
|---|---|---|---|
| `GET` | `/draft` | `draft` | Read the editable revision. Auto-creates a draft from the published revision (or empty) — never 404s for an enabled tenant. Carries `notices[]`. |
| `GET` | `/published` | `published` | Read the live revision (404 if nothing published yet). |
| `GET` | `/archive` | `archive [marker]` | Revision history, marker-paginated. **Rate limit 5/min — don't poll.** |
| `GET` | `/library` | `library` | Ready-made rule templates, grouped by category. |
| `POST` | `/draft/rules` | `upsert` | Upsert one rule: omit `id` to create (appended at the end of the chain), include an existing `id` to replace that rule. Response = full draft with recomputed `notices[]`. |
| `PUT` | `/draft/rules/order` | `reorder` | Reorder: body `{"ruleIds": [...]}` must list **every** rule id exactly once. |
| `DELETE` | `/draft/rules/{id}` | `delete <id>` | Remove one rule from the draft by id. **Idempotent** — deleting an id not in the draft still succeeds. See the consent policy below. |
| `POST` | `/draft/publish` | `publish` | Go live. Refused while any `error`-level notice exists. Previous published revision auto-archives. |
> **Publishing via the API is for the integration phase.** Once the integration is considered active, the API may **refuse to publish** (a `4xx`); the chain must then be reviewed and published by a human from the Sumsub web UI. Treat a publish refusal as expected at that stage, not an error to work around — report it and point the user to the web UI.
Token requirements beyond the usual: these are agent endpoints, so the App Token must be **AI-purpose** (created from the AI token page in the dashboard UI) and carry the `manageAmlCases` + `manageClientSettings` permissions. Symptoms of getting this wrong:
- `403 "This endpoint is not allowed for this type of token"` → the token is a regular App Token, not an AI one. Ask the user to mint an AI token.
- `404 "API is not available"` on every endpoint → the AML Resolution Rules API is not enabled for this tenant. **Stop immediately** — tell the user to contact their CSM or Sumsub support; do not retry other endpoints.
## ⚠️ Publishing affects LIVE production
**The AML Resolution Rule Chain is shared across live and sandbox** — only the AML cases / applicants are isolated, not the chain itself. Publishing changes the chain that auto-reviews **real production AML screening hits** (clearing false positives, confirming true positives, whitelisting) — a sandbox `sbx:` token does **not** make this safe. Everything up to publish is safe: reading the draft, upserting, reordering, and deleting rules all touch only the **draft**, which reviews nothing until it's published.
So publish **only on explicit user request**, and know it can be **refused by the backend**: once the chain is active — too many applicants have already been processed / the integration is complete — the API rejects the publish (a `4xx`). That is expected, not an error to work around: report it and tell the user to publish from the Sumsub web UI (see the Endpoints note).
## Auth — App Token + secret (sandbox only)
This skill talks to the public Sumsub API and signs each request per
[the authentication reference](https://docs.sumsub.com/reference/authentication).
The full how-it-works writeup lives in the [`sumsub-api-auth`](../sumsub-api-auth/SKILL.md)
skill — read it if you hit `401 Invalid signature`.
> **⚠️ Sandbox tokens only.** Do **not** accept or use a production App Token
> here — these rules auto-review real AML hits. If the user offers a prod
> token, refuse and ask them to generate a sandbox pair at
> <https://cockpit.sumsub.com/checkus/home?sbx=true> (**Connect Sumsub to
> your AI agent** -> **Build & configure** -> **Generate token** — that page
> mints the **AI** token these endpoints require, see above). Token + secret
> are shown once — copy both before closing the dialog. `aml_rules_api.py` enforces this — it rejects tokens
> that don't start with `sbx:`.
| Var | Example |
|---|---|
| `SUMSUB_APP_TOKEN` | `sbx:...` — sandbox **AI** App Token from the dashboard. |
| `SUMSUB_SECRET_KEY` | The paired secret shown once at token creation. |
| `SUMSUB_BASE` | Optional. Defaults to `https://api.sumsub.com`. |
If the user has already supplied credentials in conversation, reuse them;
otherwise ask once before running. Never echo the secret back.
## How the chain executes (keep this in mind while designing rules)
1. Rules run **top to bottom** — order matters.
2. Each rule is evaluated against **every hit that is not yet reviewed**. A hit that gets a `matchStatus` (from moderation or an earlier rule) is skipped by later rules.
3. Effects are visible **mid-run**: tags and recomputed `screening.answer` / `screening.tags` / `screening.matchStatuses` from earlier rules are seen by later ones — this enables chaining (rule A tags `match_tin`, rule B reacts to `screening.tags containsAny ["match_tin"]`).
4. Put specific, decisive rules (ID matches, previous-review carry-over) **before** broad clean-up rules — a broad `false_positive` rule placed first steals hits from preciser rules below it.
## Precondition: AML screening must actually be running
This chain acts **only on hits produced by AML screening**. If screening isn't running, the rules are inert — they evaluate against nothing. Two independent gates decide whether screening runs on a given applicant level:
1. **Tenant entitlement `WATCHLISTS`** — all-or-nothing, tenant-wide. Verify it by invoking the [`sumsub-check-permissions`](../sumsub-check-permissions/SKILL.md) skill. `WATCHLISTS` present → AML screening is available and **on by default** on every level. `WATCHLISTS` absent → AML screening is **off for the entire tenant**; no applicant-level setting can turn it on, and this rule chain will **never see a hit**.
2. **Per-level opt-out `disableWatchlists`** — only relevant once gate 1 passes. Screening runs on a level unless that level sets the top-level `disableWatchlists: true` (how a client carves out levels that shouldn't be screened). This is the [`sumsub-create-level`](../sumsub-create-level/SKILL.md) skill's concern, not this one.
| `WATCHLISTS` entitlement | Level `disableWatchlists` | Screening runs on that level? | Chain acts on its hits? |
|---|---|---|---|
| absent | (any) | **No** — off tenant-wide | No |
| present | absent / `false` | **Yes** (default) | Yes |
| present | `true` | **No** — level opted out | No |
The chain is **one per tenant** and acts on hits from **every** level where screening runs — it is not scoped to a level. So confirm gate 1 **before** building rules (step 0). If `WATCHLISTS` is missing, anything you write here stays dormant until the entitlement is enabled.
## Tags bridge into applicant workflows
Rule tags are **common applicant tags** — tenant-wide, not AML-only. They don't just chain within the AML run: they also surface in **applicant workflow** conditions as `checks.personWatchlist.tags` (individuals) and `checks.companyWatchlist.tags` (companies). That makes a tag the hand-off point between the rule chain and a workflow: the rule chain flags a nuanced situation, the workflow routes on it. Example:
1. AML rule — condition "the hit carries the `pep` risk label and the applicant is under 30", tags action `["youngPep"]`.
2. Workflow edge — a `condition` testing that `checks.personWatchlist.tags` contains `youngPep` → a manual-review node.
Both skills author the **same Condition AST** (see below), so the two halves read alike. When the user's goal includes routing or actions downstream of screening, set the tag here and point them to [`sumsub-create-workflow`](../sumsub-create-workflow/SKILL.md) for the routing half.
## Procedure
0. **Confirm AML screening is enabled for the tenant** (see *Precondition*). Invoke the [`sumsub-check-permissions`](../sumsub-check-permissions/SKILL.md) skill and verify `WATCHLISTS` is among the tenant's allowed entitlements. If it's **absent**, AML screening is off tenant-wide, so this rule chain will never fire: tell the user plainly (the entitlement is enabled by their CSM / Sumsub support), and get **explicit confirmation** before proceeding — do not silently build a chain that does nothing. If present, proceed.
1. **Read the draft**: `${CLAUDE_SKILL_DIR}/scripts/aml_rules_api.py draft`. This is also the availability check — on `404 "API is not available"` or `403`, stop and report (see Endpoints above). Review the existing rules: if a rule with the same intent already exists, offer to **update it by `id`** instead of appending a duplicate — upsert has no name deduplication. Note which rule ids exist *before* you make changes — you'll need that to honor the delete consent policy.
2. **Check the library first**: `${CLAUDE_SKILL_DIR}/scripts/aml_rules_api.py library`. If a ready-made template matches the user's intent, start from its `rule` definition — strip the `id`, adjust, and continue. Check the template's `vendors` list against the AML vendor the tenant uses.
3. **Write the rule spec** (below). Author the `condition` as the **real Condition AST**, directly — there is no expression mini-language (see *Authoring conditions*). **Scope the rule to an entity type with an `input.entityType` criterion** (default individual — see *Authoring conditions*). Verify every `exp` path against [references/aml-rules-context-fields.md](references/aml-rules-context-fields.md), and any enum-backed field's allowed values against [references/aml-rules-enumerations.md](references/aml-rules-enumerations.md) — unknown paths produce `unknownField` error notices that block publishing.
4. **Assemble the rule payload** with `${CLAUDE_SKILL_DIR}/scripts/build_aml_rule.py` — compact spec on stdin → full rule JSON on stdout. The builder is a thin assembler: it passes the `condition` AST through, assembles the review/tags actions, validates operators / the `matchStatus` enum / the emptyAction guard, and errors here (before any network call) on a typo or a malformed AST.
5. **Show the resolved payload to the user and get explicit confirmation** before the first upsert. If the rule's `tags` action introduces a tag that doesn't exist yet, call that out explicitly: upserting will create it as a **tenant-wide applicant tag** (visible in dashboard filters, workflows, everywhere) — confirm the name is one worth keeping (clear, concise, reusable) or reuse an existing tag instead.
6. **Upsert** via `${CLAUDE_SKILL_DIR}/scripts/build_aml_rule.py < spec.json | ${CLAUDE_SKILL_DIR}/scripts/aml_rules_api.py upsert` → response is the **full draft** with recomputed `notices[]`. Repeat steps 3–6 for each rule when creating several. Record the id the server assigns each rule you create — that's how you know it's yours (step 9).
7. **Inspect `notices`**. `error` notices (e.g. `invalidTag`, `unknownField`, `unknownFunction`) block publishing — fix the rule and re-upsert with its `id`. `warning`/`info` are advisory; surface them. See the notice key table in [references/aml-resolution-rules-schema.md](references/aml-resolution-rules-schema.md).
8. **Order the chain.** New rules land at the end. If the evaluation order needs changing, pass the full permutation: `echo '{"ruleIds": [...]}' | ${CLAUDE_SKILL_DIR}/scripts/aml_rules_api.py reorder`.
9. **Delete rules — only per the consent policy below.** `${CLAUDE_SKILL_DIR}/scripts/aml_rules_api.py delete <ruleId>`.
10. **Publish only on explicit request** (see the "Publishing affects LIVE production" note): `${CLAUDE_SKILL_DIR}/scripts/aml_rules_api.py publish`. Publishing makes the chain live against real AML hits **across live and sandbox** — never publish without the user asking; until then the draft is dormant. The backend may **refuse** the publish once the chain is active (too many applicants already processed / integration complete) — a `4xx`. That's expected: report it and direct the user to publish from the Sumsub web UI (see the Endpoints note).
11. **Report** (lead with rule names, never bare ids):
- The rule chain in evaluation order (names), the draft `revision`, and a notices summary.
- Draft (editable) view: [https://cockpit.sumsub.com/checkus/sdkIntegrations/globalSettings/amlScreening?revision=draft&tab=amlRules&sbx=true](https://cockpit.sumsub.com/checkus/sdkIntegrations/globalSettings/amlScreening?revision=draft&tab=amlRules&sbx=true)
- Published (live) view, after publishing: [https://cockpit.sumsub.com/checkus/sdkIntegrations/globalSettings/amlScreening?revision=published&tab=amlRules&sbx=true](https://cockpit.sumsub.com/checkus/sdkIntegrations/globalSettings/amlScreening?revision=published&tab=amlRules&sbx=true)
- Render both as clickable markdown links; `sbx=true` targets the Sandbox workspace.
- End with a dedicated `Rule id (for future updates / reorder): <id>` line — the one place a raw id belongs in your output.
- On failure: surface Sumsub's `description` / `errorName` verbatim.
### Deleting rules — consent policy
The `delete` subcommand removes a rule from the draft permanently (it's archived with the revision, but gone from the editable chain). Apply this gate:
- **A rule you created in this session** — delete it freely when it was a mistake, the user asked you to replace it, or you're iterating on your own work. (Updating in place by `id` is usually better than delete-then-recreate, because it preserves the rule's position; reach for delete when the rule should genuinely go away.)
- **Any rule you did not create** — a pre-existing rule, one from an earlier session, or one a human authored — delete **only with explicit user consent for that specific rule**. Name the rule (by its `name`) and confirm before deleting. Never delete someone else's rule as a side effect of "cleaning up" or "rebuilding" the chain.
- When unsure whether a rule is yours, treat it as not yours and ask.
Deleting is idempotent and returns the full draft with recomputed `notices[]` — re-inspect them afterward (removing a rule can change which tags/fields the remaining rules still justify).
## Rule spec format (JSON on stdin)
```jsonc
{
"name": "Auto-clear hits with incompatible date of birth", // required
"description": "Optional free-form description",
"id": "existing-rule-id", // only when updating an existing rule
// condition — the real Sumsub Condition AST, authored directly (see below).
"condition": {
"or": [
{ "and": [
{ "op": "ne", "args": [ { "exp": "input.entityType" }, { "lit": "\"company\"" } ] }, // individual-only — scope every rule (see Authoring conditions)
{ "op": "eq", "args": [ { "exp": "screening.vendor" }, { "lit": "\"complyAdvantageCSOM\"" } ] },
{ "op": "eq", "args": [ { "exp": "match.dobs.hasData" }, { "lit": "true" } ] },
{ "op": "call", "args": [ { "exp": "dobsIncompatible" }, { "exp": "match.dobs" }, { "exp": "input.dob" }, { "lit": "\"weakContainment\"" } ] }
] }
]
},
// Review action — what to set on matched hits (every field optional):
"review": {
"matchStatus": "false_positive", // unknown | potential_match | false_positive | true_positive
"riskLevel": "LOW", // vendor-specific — omit when unsure
"whitelisted": true, // accept the risk: allows approval despite the hit
"note": "Cleared automatically: DoB mismatch"
// OR the whole-review form (replaces all per-field keys):
// "expression": "match.previousReview" // carry over the previous screening's decision
},
// Tags added to the AML case when the rule matches. These are common applicant
// tags (tenant-wide, usable everywhere — not AML-only). A tag that doesn't exist
// yet is created automatically on upsert: prefer reusing an existing tag, and
// name new ones clearly and concisely (e.g. "youngPep", not "rule3_tag"):
"tags": ["auto_cleared_dob"]
}
```
A rule needs `review` and/or `tags` — neither yields an `emptyAction` error notice. Review fields take a scalar (the builder JSON-encodes it into a `{lit}`) or an explicit `{"exp": "..."}` / `{"lit": "..."}` to compute or supply the value directly.
## Authoring conditions
A rule's `condition` is the **real Sumsub Condition AST, written directly** — the *same* shape and rules as workflow edge conditions, so the two skills stay aligned. There is **no expression mini-language** (the old `condition: "screening.vendor = …"` string and `conditionRaw` key were removed; the builder rejects both with a pointer here).
> **⚠️ Scope every rule to an entity type.** Individual and company matches expose different fields and read differently, so a rule built for one can misfire on the other. **Each rule MUST carry an `input.entityType` criterion** — infer the target from the rule's intent (person name / DoB / PEP signals → individual; company name / KYB signals → company), and **default to individual** when unsure:
> - Individual (the usual case): `{ "op": "ne", "args": [ {"exp": "input.entityType"}, {"lit": "\"company\""} ] }` — entityType ≠ company.
> - Company / organization: `{ "op": "eq", "args": [ {"exp": "input.entityType"}, {"lit": "\"company\""} ] }`.
>
> Omit it **only** for a deliberately entity-agnostic rule (rare — e.g. "whitelist every `sanctions` hit from country X"). Most library templates already carry this criterion — keep it when you adapt one.
```jsonc
{
"or": [ // OR of AND-groups; first matching branch wins
{ "and": [
{ "op": "eq", "args": [ {"exp": "screening.vendor"}, {"lit": "\"complyAdvantageCSOM\""} ] },
{ "op": "in", "args": [ {"exp": "input.country"}, {"lit": "[\"BRA\", \"ARG\"]"} ] }
] }
]
}
```
- **`{exp: "<path>"}`** — an expression path, verbatim (from [references/aml-rules-context-fields.md](references/aml-rules-context-fields.md)). It's the left side of a comparison, and also the **right** side for field-to-field checks (`{exp: "match.analysis.parentName"}`).
- **`{lit: "<json>"}`** — a literal, supplied **already JSON-encoded as a string** (the form the API stores): `"\"complyAdvantageCSOM\""`, `"2"`, `"true"`, `"[\"pep\", \"sanctions\"]"`. An un-encoded literal (`{lit: "BRA"}`) is rejected by the API, so the encoding is required.
- **`op`** is one of the comparison operators (`eq ne lt lte gt gte in notIn contains notContains containsAny notContainsAny containsAll containsOnly startsWith endsWith match empty notEmpty eqIgnoreCase …`; full set in [references/aml-resolution-rules-schema.md](references/aml-resolution-rules-schema.md)). The builder validates every `op` against its known set.
- **Set membership on list fields** (`match.riskLabels`, `screening.tags`, `match.complyAdvantage.matchTypes`): `contains` / `containsAny` = *has* the value(s); `containsAll` = *has all of*; **`containsOnly` = *has nothing but* the listed values**. To match "**only** label X" (e.g. adverse-media-only hits) use `containsOnly ["X"]` — **never** `notContainsAny [the-other-labels]`, which leaks because it silently assumes you enumerated the whole closed vocabulary (the risk-label set is exactly six — see [references/aml-rules-enumerations.md](references/aml-rules-enumerations.md#AmlRiskLabel)).
- **`call`** invokes a built-in function — the one operator AML conditions have that workflows don't. The first arg is the function name as `{exp: "namesIncompatible"}`, the rest are its arguments: `{op: "call", args: [{exp: "dobsIncompatible"}, {exp: "match.dobs"}, {exp: "input.dob"}, {lit: "\"strict\""}]}`. Functions and their signatures: [references/aml-resolution-rules-schema.md](references/aml-resolution-rules-schema.md).
- **Negation** is expressed with the `not*` operators (`notIn`, `notContains`, `notContainsAny`, `ne`, `notEmpty`, …) — do **not** set a `negate` flag; a top-level `condition.negate` is rejected (UI-unsupported), matching the workflow skill.
- **Dates**: any `Date` field (`input.dob`, `match.previousReview.modifiedAt`, `match.sources.latestEndDate`) exposes `.ageInDays` / `.ageInYears` / `.year` etc. — e.g. `{op: "lt", args: [{exp: "match.previousReview.modifiedAt.ageInYears"}, {lit: "2"}]}`.
The full `Condition`/`And`/`Criterion` AST, literal-encoding rules, operator list, and built-in function signatures live in [references/aml-resolution-rules-schema.md](references/aml-resolution-rules-schema.md); valid `exp` paths are in [references/aml-rules-context-fields.md](references/aml-rules-context-fields.md) and enum-field values in [references/aml-rules-enumerations.md](references/aml-rules-enumerations.md) (both indexed by [references/aml-rules-expressions.md](references/aml-rules-expressions.md)).
## Worked examples
- [`examples/minimal.json`](examples/minimal.json) — tag-only rule (`containsOnly` on risk labels).
- [`examples/auto-clear-dob-mismatch.json`](examples/auto-clear-dob-mismatch.json) — `call` functions + vendor match types; the most common auto-clear topology.
- [`examples/reuse-previous-review.json`](examples/reuse-previous-review.json) — whole-review `expression` form carrying over a previous decision, with a date-age condition. Also the rare **entity-agnostic** rule (carrying a prior decision reads the same for individuals and companies) — the one case that needs no `input.entityType` filter.
## See also
- [references/aml-resolution-rules-schema.md](references/aml-resolution-rules-schema.md) — endpoints with rate limits, every field and enum, condition AST shape, operator list, built-in function signatures, notice message keys, execution model.
- [references/aml-rules-expressions.md](references/aml-rules-expressions.md) — expression-reference **index** + preamble (root variables, `applicant`/`Date` shapes, the enum caveat). Points to the two data files below; all three are lookup databases, don't read end-to-end.
- [references/aml-rules-context-fields.md](references/aml-rules-context-fields.md) — every legal `exp` path with its type (`## Types` / `## Index` / `## Functions`).
- [references/aml-rules-enumerations.md](references/aml-rules-enumerations.md) — allowed **values** for the closed-enum fields (risk labels, match statuses, PEP institution kinds, match types, …).
- [`sumsub-create-workflow`](../sumsub-create-workflow/SKILL.md) — authors the **same Condition AST** in workflow edge conditions; its expressions reference covers the `applicant` / `applicantIdDoc` shapes.