A Go AML engine: a configurable rules DSL over expr-lang with weight-of-evidence scoring, sanctions screening against OFAC/UN/EU/HMT, case management, and scored alerting. Single binary, embedded SQLite per org, embedded UI. Surface (all /v1, org-scoped): /v1/aml/transactions ingest and score /v1/aml/rules /rules/test the rules DSL and its dry-run /v1/aml/sanctions/search list screening /v1/aml/cases investigation workflow /v1/aml/health Build: make build produces one static binary with the UI embedded (embedui tag); bare Go tooling needs no Node. Third-party license and notice texts are generated from the module graph the linker actually walks and ship beside every artifact. Co-authored-by: Hanzo Dev <[email protected]>
13 KiB
amld — Integration Patterns
This document describes how amld integrates with the rest of the
stack. It is deliberately generic: it specifies the interfaces,
data shapes, and control flow, not any particular commercial
data provider. Operators choose their own watchlist, PEP, and
adverse-media sources and adapt them to the schemas below.
amld is the AML / FinCrime evaluation service. It exposes a
versioned HTTP API on /v1/aml/*, persists rules / alerts / cases
through a Hanzo Base–backed store, evaluates per-transaction risk
synchronously, emits signed webhooks for downstream review, and
ships with a starter rule library covering the standard typologies.
The integration surface has six concerns:
- Sanctions / watchlist screening
- Politically Exposed Person (PEP) screening
- Adverse-media screening
- Velocity and amount-threshold limits
- Customer risk tiers
- Pre-transaction hooks for caller services (
bankd, exchange, payments) - Webhook + case-management surface for human review
1. Sanctions / Watchlist Screening
amld treats sanctions data as a normalised, append-only catalog
that any number of upstream lists can feed. The canonical model is in
pkg/types/types.go:
type SanctionsList struct {
ID string // internal id
Source string // free-form source identifier
URL string // origin URL
Format string // xml | csv | json | ...
FetchedAt time.Time
SHA256 string // integrity hash of the raw payload
Active bool
}
type SanctionsEntry struct {
ID string
ListID string // back-ref to SanctionsList.ID
RefID string // upstream-stable identifier
Name string
Aliases []string
DOB string // best-effort, often partial
Nationality string
Address string
Type string // individual | entity | vessel | aircraft
Raw json.RawMessage // original record preserved verbatim
}
The shipped list-source constants (ofac_sdn, un, eu, hmt,
interpol) are category tags, not bindings to any particular
publisher. Operators can ingest:
- Government-published consolidated lists (OFAC SDN / Consolidated, UN Security Council, EU Consolidated Financial Sanctions, UK HMT OFSI, regional regulators).
- Internal block lists (denylists from internal investigations, closed customer relationships, etc.).
- Third-party aggregated watchlist feeds — the same ingest path applies; only the parser differs.
Ingest contract
A list ingester is a function that:
- Fetches the upstream payload.
- Computes its SHA-256 (recorded on
SanctionsList). - Parses it into
[]SanctionsEntryrows tagged with the parentListID. - Persists rows transactionally — partial ingests must not leave half-loaded lists active.
The matcher (pkg/sanctions/match.go) is source-agnostic. It scores
incoming names against Name and every Alias using token-based
similarity and returns hits above a configurable threshold.
Refresh cadence
pkg/sanctions/refresh.go defines the periodic refresh loop. Cadence
is policy: regulated counterparties typically require daily refresh
of consolidated government lists; internal lists update on commit.
2. PEP Screening
PEP status is a property of the entity, not of the transaction. The
canonical flag lives on Entity:
type Entity struct {
ID string
OrgID string
EntityType string // user | account | counterparty | bank_account
Name string
Jurisdiction string
KYCLevel int
PEP bool // <-- politically exposed
SanctionsFlag bool
RiskScore float64
}
PEP is a boolean on the resolved entity. The recommended pattern:
- Upstream KYC pipeline (in
bankdor a dedicated onboarding service) determines PEP status at customer-onboarding and on periodic re-screen. - The result is written to the AML
Entityrecord.amldis the consumer, not the resolver — it does not embed a PEP database. - Rules consume
entity.PEPdirectly via the expr-lang DSL:entity.PEP && tx.Notional > 1000.
The same shape supports relatives-and-close-associates (RCA) and
PEP-by-association — encode them either as additional boolean
attributes on Entity (via a future-compatible extension column) or
as separate watchlist categories ingested through the sanctions path
above. The data model intentionally does not over-specify PEP
sub-categories so that operators can match the granularity their
regulator requires.
3. Adverse-Media Screening
Adverse-media findings are modelled as evidence attached to a case, not as a primary signal. The integration shape:
-
An external adverse-media screen (operator's choice) produces a finding: subject identifier, severity, source URL, headline, excerpt, date.
-
The finding is posted as a
CaseEventof kindnote(orfileif a snapshot PDF is attached) on a case opened for the subject:POST /v1/aml/cases/{id}/events { "kind": "note", "body": "Adverse media match — <category> — <source> — <date>" } -
For automated adverse-media gating before a transaction settles, the upstream screen pushes an adverse-media risk score into the
Entity.RiskScorefield (or a derived attribute) so the rule engine can read it via expr-lang:entity.RiskScore > 0.7.
This keeps amld neutral about the adverse-media provider while
giving operators a clean place to materialise findings into the case
record for examiner review.
4. Velocity & Amount-Threshold Limits
Velocity and threshold rules are expressed in the rule DSL. The
starter library (pkg/rules/library.go) ships representative
examples:
| Rule | DSL |
|---|---|
| CTR threshold | notional_usd(tx) > 10000 |
| Structuring | tx.Notional >= 9000 && tx.Notional < 10000 |
| Velocity (24h) | sum_last_24h(tx.UserID) > 50000 |
| Velocity (30d) | sum_last_30d(tx.UserID) > 500000 |
| First-time large | first_tx(tx.UserID) && tx.Notional > 25000 |
| Round-trip | is_round_trip(tx.UserID, "24h") |
Helper functions (pkg/engine/helpers.go) provide the temporal
aggregates: sum_last_24h, sum_last_30d, count_last_24h,
first_tx, is_round_trip, notional_usd. Operators add their
own helpers by registering them on the evaluator.
Each rule carries:
Severity: low | medium | high | criticalWeight: contribution to the aggregate risk score (0..1)Action: allow | flag | review | block | reportJurisdictionFilter/AssetClassFilter: scope guardsPriority: deterministic evaluation order
Negative weights are rejected at write time (RED-19 hardening) — a rule cannot subtract from the score.
5. Customer Risk Tiers
There is no separate "risk-tier" table. Tiering is modelled by the
combination of two Entity fields:
KYCLevel(int) — the verification depth completed.RiskScore(float, 0..1) — the rolling risk score from accepted alerts.
A rule expresses a tier in DSL:
entity.KYCLevel < 2 && tx.Notional > 1000
entity.RiskScore > 0.6 && tx.AssetClass == "crypto"
The aggregate score returned by engine.Evaluate is the
weight-sum of matching rules, clipped to [0, 1]. The action taken
is the most severe of the matching rules' actions (report >
block > review > flag > allow for ordering purposes), with
ties broken by Priority.
6. Pre-Transaction Hooks (bankd and other callers)
Caller services call amld synchronously on the hot path. The
contract is one POST per transaction:
POST /v1/aml/transactions
Headers:
X-Org-Id: <tenant>
Authorization: Bearer <iam-jwt>
Body:
{
"id": "<caller-tx-id>", // optional; generated if absent
"user_id": "<entity-id>",
"account_id": "<account-id>",
"symbol": "<asset>",
"asset_class": "fiat|crypto|equity|...",
"side": "buy|sell|in|out",
"qty": 123.0,
"notional": 12300.00,
"currency": "USD",
"counterparty":"<counterparty-id>",
"ip_address": "...",
"device_fingerprint":"...",
"timestamp": "2026-05-23T12:34:56Z"
}
The response is the synchronous decision:
{
"action": "allow|flag|review|block|report",
"score": 0.42,
"alert_ids": ["..."],
"case_id": "..." // present when action ∈ {review,block,report}
}
Caller contract (e.g., bankd)
Pseudocode at the caller:
res := amld.Evaluate(tx)
switch res.Action {
case "allow":
proceed()
case "flag":
proceed() // async review; do not block settlement
case "review":
park(tx, res.CaseID) // hold until case resolved
case "block":
reject(tx, res.AlertIDs) // hard stop
case "report":
proceed() // settle, but a regulatory report
// (e.g., CTR) is now owed
}
The caller MUST treat any non-200 response as fail-closed for regulated transactions (i.e., reject), and as fail-open only for explicitly designated low-risk lanes. The default posture is fail-closed.
The hook is a single round-trip; amld does not call back into
the caller during evaluation. Asynchronous review happens via the
case + webhook surface described next.
7. Webhooks & Case Management
When a transaction transitions out of allow, two things happen:
- A
Caseis opened (or extended) with the firingAlertrows attached. - A signed webhook is dispatched to every subscriber registered for the relevant event.
Webhook contract
Subscriber config:
type Webhook struct {
ID string
OrgID string
URL string
Secret string
Events []string // aml.flagged | aml.cleared | case.opened | ...
Enabled bool
}
Delivery is HTTP POST with:
Content-Type: application/jsonX-Webhook-Event: <event-name>X-Webhook-Signature: <hex(hmac-sha256(body, secret))>
Body:
{
"event": "case.opened",
"timestamp": "2026-05-23T12:34:56Z",
"data": { ... }
}
Delivery is at-least-once with exponential backoff up to
webhook.MaxRetries attempts; persistent failures dead-letter.
Outbound URLs are validated against SSRF (RED-18 hardening) — no
loopback, no link-local, no metadata endpoints.
Case API
The case-management surface is HTTP-only — there is no embedded
reviewer UI in amld proper (the dashboard is mounted by Base at
/_/aml/).
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/aml/transactions |
Submit tx, get decision |
| GET | /v1/aml/transactions/{id}/alerts |
Alerts for a tx |
| GET | /v1/aml/cases |
List cases (filterable) |
| POST | /v1/aml/cases/{id}/events |
Append note / file / status change |
| GET | /v1/aml/rules |
List active rules |
| POST | /v1/aml/rules/test |
Dry-run a rule against a sample tx |
| POST | /v1/aml/sanctions/search |
Name search against loaded lists |
| GET | /v1/aml/health |
Liveness |
All routes require X-Org-Id and an authenticated IAM session.
Data is scoped per-org; cross-org reads are not possible.
A typical reviewer flow:
- Case opens →
case.openedwebhook fires → reviewer's case-management UI ingests it. - Reviewer investigates; appends notes / files / a status change
via
POST /v1/aml/cases/{id}/events. - Reviewer resolves the case (
cleared|sar_filed|account_frozen|false_positive); this drives thecase.closedwebhook and updates the rollingEntity.RiskScorefor future evaluations.
Operational invariants
- Synchronous, single-round-trip evaluation. Callers do not wait on review; they wait only on rule evaluation.
- Per-org isolation. All persisted rows carry
OrgID; queries scope byX-Org-Idenforced server-side. - Fail-closed on rule error. A rule that errors counts as
unevaluated with
EvalErrpopulated; the operator-configured default action applies (default:review). - Append-only sanctions catalog. Lists are content-addressed by SHA-256; refreshes never mutate prior entries in place.
- Signed, retried webhooks. HMAC-SHA256 over the body with the subscriber's secret; outbound URLs SSRF-validated.
- No vendor lock-in. Every external data category — sanctions, PEP, adverse-media — is consumed via a generic schema. Swapping providers means swapping ingesters, not rewriting the engine.