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
luxfi/aml
Overview
Go module: github.com/luxfi/aml
Real-time AML/CFT transaction monitoring engine. Pure Go, single binary, embedded SQLite via Hanzo Base. Configurable rules DSL (expr-lang), sanctions screening (OFAC/UN/EU/HMT), case management, scored alerting.
Tech Stack
- Language: Go 1.26.4
- Database: Hanzo Base (embedded SQLite per-org)
- Rules DSL: github.com/expr-lang/expr v1.17
- Scoring: Weight-of-evidence (pure Go math)
- Sanctions: Jaro-Winkler + token-based fuzzy name matching
- HTTP: Hanzo Base router (net/http)
- Auth: X-Org-Id header (set by gateway from JWT owner claim)
Build & Run
make build # UI build + Go build -tags embedui -> ./amld (the product)
make test # go test -race -count=1 ./...
make vet # go vet ./...
make ui # pnpm install --frozen-lockfile && pnpm build -> ui/dist
make run # Build + run dev
make build is the only command that produces the shippable binary: the admin
dashboard is embedded, so it always builds ui/dist first and links with
-tags embedui.
Bare go build ./..., go vet ./..., go test ./... and go install work on a
fresh clone with no Node toolchain — without the embedui tag, ui/embed.go
supplies a placeholder tree instead of embedding ui/dist. That is what keeps
the module installable, since ui/dist is a build artifact and is not committed.
Module Layout
cmd/amld/main.go -- Single binary: serve, version, embedded admin UI
migrations/0001_core.sql -- 9 SQLite collections
ui/ -- Embedded admin dashboard (Vite + React + @hanzo/gui)
embed.go -- !embedui: placeholder DistDirFS(), no dist/ needed
embed_prod.go -- embedui: go:embed all:dist -> DistDirFS()
package.json -- @luxfi/aml-ui (private, pnpm)
vite.config.ts -- base: /_/aml/, proxy /v1/aml to :8090
src/
main.tsx -- React 19 entry
App.tsx -- Hash router (wouter), sidebar nav, 5 routes
api.ts -- Typed fetch wrappers for /v1/aml/* endpoints
pages/
Dashboard.tsx -- Stats cards, recent cases, health check
Cases.tsx -- DataTable with status filter tabs, expandable rows
Rules.tsx -- DataTable with DSL display, test rule modal
Alerts.tsx -- DataTable with severity filter chips, score breakdown
Sanctions.tsx -- Name search form, results table with scores
dist/ -- Built output (gitignored, ~220KB)
pkg/
types/types.go -- Canonical domain types (Transaction, Entity, Rule, Alert, Case)
engine/
engine.go -- Core engine: evaluate tx -> alerts + score + action
evaluator.go -- expr-lang compiler + evaluator with helper functions
scoring.go -- Weight-of-evidence scoring + ScorerPlugin interface
rules/library.go -- 20 starter AML rules (CTR, structuring, velocity, sanctions, etc.)
sanctions/
match.go -- Jaro-Winkler + token-based fuzzy name matching
lists.go -- Default list configurations (OFAC, UN, EU, HMT)
ingest.go -- OFAC SDN XML parser + HTTP fetcher
cases/
case.go -- Case store (create, update status, assign, resolve, events)
errors.go -- Sentinel errors
webhook/webhook.go -- Signed delivery (HMAC-SHA256) with retry + dead-letter
api/routes.go -- /v1/aml/* HTTP routes + SanctionsStore on Base
API Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /v1/aml/transactions | Ingest transaction, sync rule eval, returns action |
| GET | /v1/aml/transactions/{id}/alerts | Alerts for a transaction |
| GET | /v1/aml/cases | List cases (filter by status) |
| POST | /v1/aml/cases/{id}/events | Add case event (note, status change) |
| GET | /v1/aml/rules | List all rules |
| POST | /v1/aml/rules/test | Dry-run a DSL expression |
| POST | /v1/aml/sanctions/search | Search sanctions lists by name |
| GET | /v1/aml/health | Health check |
All endpoints require X-Org-Id header.
Embedded Admin UI
Served at /_/aml/ via go:embed. Hash router (/#/cases, /#/rules, etc.) for SPA routing without server rewrites.
make build # builds ui/dist, then links it with -tags embedui
./amld serve --dev # UI at http://localhost:8090/_/aml/
Without -tags embedui the binary serves a placeholder page at /_/aml/; that
is the expected result of a bare go build/go install.
Dev mode: cd ui && pnpm dev (port 3000, proxies API to :8090).
Tech: React 19, wouter (hash router), @hanzo/gui, Vite 6. Dark theme.
Transaction Ingest Flow
POST /v1/aml/transactions
-> Parse + validate
-> Resolve entity from user_id
-> EvalAll(rules, {tx, entity})
-> Filter by jurisdiction, asset_class, enabled
-> Compile + run each rule DSL via expr-lang
-> Score(hits) -> weight-of-evidence [0,1]
-> resolveAction(alerts) -> highest severity action wins
-> If block/report: auto-create Case
-> Return {action, score, alert_ids, case_id?}
Starter Rules (20)
| # | Rule | DSL Pattern | Action |
|---|---|---|---|
| 1 | CTR Threshold | notional > 10000 && USD | report |
| 2 | Structuring | notional in [9000, 10000) | flag |
| 3 | Velocity Daily | sum_last_24h > 50000 | review |
| 4 | Velocity Monthly | sum_last_30d > 500000 | review |
| 5 | First-Time Large | first_tx && notional > 25000 | review |
| 6 | Round-Trip | is_round_trip 24h | flag |
| 7 | Sanctioned Jurisdiction | jurisdiction in sanctioned list | block |
| 8 | PEP Large | PEP && notional > 10000 | review |
| 9 | Unusual Hour | hour in [2,3,4] | flag |
| 10 | New Counterparty Large | first_counterparty && notional > 5000 | review |
| 11 | Dormant Reactivation | last_tx > 180d && notional > 10000 | review |
| 12 | Crypto Mixer | is_mixer_address | block |
| 13 | Darknet Market | is_darknet_market | block |
| 14 | IP/Geo Mismatch | !geo_match | flag |
| 15 | Layering | layering_score > 0.8 | review |
| 16 | Smurfing | smurfing_detected 7d | review |
| 17 | Wash Trading | wash_trade_detected 1h | flag |
| 18 | Sanctions Direct | sanctions_hit(entity) | block |
| 19 | Sanctions Counterparty | sanctions_hit(counterparty) | block |
| 20 | Travel Rule | notional > travel_rule_threshold | report |
Compliance Client Compatibility
luxfi/compliance AML client connects via HTTP:
| Client Method | Endpoint |
|---|---|
| ScreenTransaction | POST /v1/aml/transactions |
| CheckSanctions | POST /v1/aml/sanctions/search |
| CreateCase | POST /v1/aml/cases/{id}/events |
| GetCases | GET /v1/aml/cases |
Webhook events: aml.flagged, aml.cleared, kyc.approved, trade.executed.
Deferred
- ML scoring via Hanzo Zen gateway (ScorerPlugin interface ready)
- Hanzo Tasks durable workflows (sanctions-refresh, case-automation, backtest)
- Base realtime subscription for live transaction monitoring
- Backtest/batch evaluation against historical data
- SAR draft generation
- Full OFAC/UN/EU/HMT list refresh automation
- Base collection persistence (current: in-memory stores for v1 engine)
Dependency pinning
Every dependency resolves through proxy.golang.org and is verified against
sum.golang.org. Both are append-only: the proxy serves the zip it first
recorded for a version, and the checksum database is a transparency log of that
zip's hash. A tag that moves afterwards therefore cannot change what this repo
compiles — which is the whole point, because tags in this fleet do move.
The builds pin that policy themselves rather than inheriting it. A machine may
carry a go env file whose GOPRIVATE/GONOPROXY/GONOSUMDB route
luxfi/* and hanzoai/* around both, so ci.yml and release.yml set
GOENV: off, the one documented switch that ignores the file. GOPRIVATE= in
the environment does not override it: Go reads an empty environment variable
as unset and falls back to the file.
Rules:
-
Never write a hash
sum.golang.orgdoes not corroborate. The log is the authority; ifgo.sumdisagrees with it,go.sumis wrong. Two commits in this history (9821aff,77173ad) wrote hashes taken from whatever the git host was serving, which is the failure this rule exists to prevent.curl -sS https://sum.golang.org/lookup/github.com/luxfi/[email protected] -
go mod verifydoes not checkgo.sum. It compares the extracted module against the hash recorded in the local module cache, so it reports "all modules verified" while ago.sumentry that matches nothing sits right next to it. Only a download can catch that — on a cleanGOMODCACHE, since a cache entry fetched from the git host keeps serving those bits. -
Prefer the lowest unmoved patch successor (
x.y.z->x.y.z+1). Never jump a major; never go abovev1.x.x. -
If a module has no version whose host content, proxy content, and log record all agree, that repo's owner must cut one. Do not work around it here.
Tags known to have been force-moved, and how each was resolved:
| Module | Moved tag | Resolution |
|---|---|---|
github.com/luxfi/age |
v1.5.0 | pinned v1.5.1 — same commit b13d4cb0, unmoved |
github.com/luxfi/threshold |
v1.9.4 | pinned v1.9.5 — same commit 0080201, unmoved |
github.com/luxfi/pq |
v1.0.3 | resolved in place: move was content-neutral, go.sum carries the log hash |
github.com/hanzoai/base |
v1.1.0 | go.sum carries the log hash; owner action for a version host and log agree on |
github.com/hanzoai/kms/sdk/go |
v1.0.0 | graph-only, not in go.sum |
github.com/luxfi/mpc |
v1.14.13 | graph-only, not in go.sum; host and log disagree |
github.com/luxfi/precompile |
v0.5.37 | graph-only, not in go.sum; host and log disagree |
luxfi/age and luxfi/threshold are the pattern to copy: a successor tag cut at
the identical commit, so pinning it changes no code and restores corroboration.
Two entries needed more than a successor tag:
luxfi/pqv1.0.3 moved frome16b004dto90d2223e, but the two commits have byte-identical trees, so the zip hash never changed. The proxy, the log, and the git host all reporth1:pFlQm1+5...;go.sumhad recordedh1:ksw1dmfT..., which corresponds to nothing obtainable and made a cold-cache build fail outright.go.sumnow carries the log hash. No successor tag is needed.hanzoai/basev1.1.0 moved fromfe516fdctoa0b4a9f3, and the content did change — in exactly three files,README.md,docs/NETWORK.md, anddocs/NETWORK_RED_REVIEW.md. No Go source and nogo.moddiffers, so the two trees compile identically.go.sumcarries the log hash (h1:Mi4yRgR1..., thefe516fdctree). A machine whose module cache already holds thea0b4a9f3bits will report a mismatch until that entry is evicted; a clean cache resolves it from the proxy. The durable fix is for the owner to cutv1.1.1ata0b4a9f3so host, proxy, and log agree on one version.
Third-party notices
The distributed artifacts carry THIRD-PARTY-NOTICES: the license, notice, and
patent-grant texts of every module the linker puts in amld, reproduced
verbatim. Apache-2.0 §4(d) requires the attribution notices of Apache-licensed
dependencies to travel with each distributed copy, and 10 of the linked modules
ship a NOTICE file; MIT/BSD/ISC require their copyright and permission notices
to travel the same way.
go run ./internal/notice generates it from the build's own module graph, so it
cannot describe a dependency set the artifact does not have and cannot go stale
when a dependency changes. Output is deterministic — modules sorted by path, no
timestamps — so it does not perturb a reproducible build.
It is generated, not committed (.gitignore), and produced on all three
distribution paths: make build (via the notice target), the goreleaser
archive (a before hook plus an archives.files entry), and the container image
(build stage, copied to /app/THIRD-PARTY-NOTICES beside /app/LICENSE).
Regenerate with make notice; verify by inspecting a built archive or the image
filesystem, never by reading the config.
Deployment
- Single binary:
amld serve --http=0.0.0.0:8090 - Docker:
ghcr.io/luxfi/aml:{env} - K8s: Deployment, replicas=1 (SQLite single-writer), PVC for /data
- Replication: hanzoai/replicate sidecar for age-encrypted S3 WAL streaming
Test Coverage
133 tests across 8 packages. All pass with -race.
- engine: 38 tests (evaluator, scoring, integration)
- cases: 28 tests (CRUD, status, events, assignment, resolution)
- sanctions: 24 tests (Jaro-Winkler, normalize, token match)
- rules: 21 tests (all 20 starter rules compile + individual eval)
- chain: 7 tests (Chainalysis client: entities, transfers, webhooks)
- workflows: 7 tests
- webhook: 5 tests (sign, verify, deliver, failure)
- api: 3 tests