Files
aml/LLM.md
T
zeekayandHanzo Dev b73c14a51a brand: prove the bearer scheme is compared, and name what the pin does not settle
A negative row presented the token as "Basic <jwt>", six characters where Bearer
is seven, so a verifier that never compared the scheme would still have refused it
— on a token it had read one character into. Digest is exactly as long, so the row
now fails for the reason it claims to test. Killing that mutant is what showed the
test was passing on the wrong evidence.

The issuer pin stops another brand's token; it does not make org names unique
across issuers, and the tenant here IS the org name. Stated where the decision
lives: one record plane serving several brands has to scope by brand as well as by
org, one plane per brand does not.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 21:33:33 -07:00

25 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: IAM access token — the owner claim of a bearer JWT the request's own brand issued (api.IAMIdentity); or, behind an authenticating gateway, the header it writes from that same claim (api.TrustedProxyHeader)
  • White-label: brand per request Host (pkg/brand, mirroring HIP-0111) — one amld serves lux.network, hanzo.ai, zoo.ngo, pars.network

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 + the Scorer seam
  velocity/velocity.go     -- Constant-time sliding aggregates per (key, window):
                              bucketed ring, fixed memory per key, Deviation
  anomaly/
    forest.go              -- Half-space trees: data-independent geometry, mass
                              counters, exponential reference fold, mass invariant
    feature.go             -- The typology->feature inventory (9 dimensions, each
                              citing the instrument behind it) + projection
    anomaly.go             -- Per-tenant model, Appetite, attribution by
                              counterfactual, State, Snapshot/Restore
  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
  retention/
    record.go              -- Record, the three Art. 77(3) clocks, expiry arithmetic
    ledger.go              -- Retain/Close/Extend, party index, purpose-gated reads,
                              five-year lookback, proven disposal
    cron.go                -- Daily disposal at 03:30 UTC
  token/token.go           -- Per-org HKDF key schedule; deterministic pseudonyms
                              for index keys, AES-256-GCM seal for record bodies
  replay/replay.go         -- Rule sandbox: replays a candidate over real history
                              through the engine's own evaluator, writes nothing
  webhook/webhook.go       -- Signed delivery (HMAC-SHA256) with retry + dead-letter
  api/routes.go            -- /v1/aml/* HTTP routes + SanctionsStore on Base
  api/records.go           -- The one place retention, token and replay are joined
  api/anomaly.go           -- Model state for governance + candidate scoring
  api/iam.go               -- Identity from an IAM token: brand of the Host pins the
                              issuer, `owner` claim is the tenant, JWKS with a
                              bounded cache
  api/brand.go             -- GET /v1/aml/config: the brand identity of this Host
  brand/brand.go           -- Brand id and request Host -> issuer + domains
                              (HIP-0111; the same registry as hanzoai/cloud brand)

Behavioural detection

Rules catch typologies someone has already named. anomaly answers the other question — is this where this customer's behaviour normally lives — which is the statutory test, since consistency is assessed against knowledge of this customer and no fixed threshold can express that.

Detector: half-space trees (Tan, Ting & Liu, IJCAI 2011). Trees are built before any data arrives — a random dimension split at the midpoint of the node's range — so there is no training pass, no sample retained, and no retraining job: the model is a set of mass counters over a fixed geometry. Scoring walks a fixed depth. Rejected: isolation forest (batch, so a retraining job per tenant, and attribution needs a bolt-on), and robust random cut forest (stores the points, and its attribution is to other points rather than to features — the wrong shape for the question a supervisor asks).

Two deliberate departures from the paper, both recorded at the code:

  1. Votes are averaged, not summed. mass * 2^depth is heavy-tailed, so one tree finding the point in a dense region returns a value many times the uniform one and swamps every tree that found emptiness. Ranking survives that; a calibrated score does not, and an appetite expressed as a quantile needs a calibrated score. Each tree is clamped to [0,1] against its own root mass before averaging, which also makes the score self-calibrating during warm-up.
  2. The reference folds exponentially (Blend, default 0.25) rather than being replaced. Blend: 1 is the published algorithm. Below 1, making the reference look like your own behaviour needs sustained volume against the whole tenant rather than one window's worth.

Why attributability decided it. HIP-518 §6 admits ML detection only behind a typology→feature mapping, a stated miss-rate appetite, and per-alert feature attribution. The third eliminates a neural scorer: its contribution to a score is not attributable to an input, so it needs a second model fitted to guess at its own reasons. A tree is interrogated directly — move one coordinate to its neutral value, rescore, and the drop is that feature's contribution, exactly, on the model that raised the alert. Cause.Without carries that counterfactual.

The appetite is the governed knob. Appetite.Review is the share of the stream the model may send for examination; the threshold is the quantile of observed scores that admits it, recomputed each window, taking the upper bucket edge so the realised share stays at or below the stated one. The miss rate cannot be computed without labels, so Appetite.Sample retains a share of non-alerted transactions for review below the line — selected by hash of the transaction id, so the sample is reproducible and cannot be steered. GET /v1/aml/anomaly reports stated against realised.

What it cannot do. Evidence is capped at types.ActionCeiling (review): the model summons a person, it does not act. Weight is non-negative and NaN/Inf are rejected, so it can only add to a score the rules built, never subtract. A panic in the Scorer is contained and counted — the rule plane's verdict stands. Where it cannot score (warming, unusable coordinates, no subject) the transaction is evaluated on rules alone and the refusal is counted per reason, because silence must never read as a clean result.

Models are per tenant, including the tree geometry, seeded from mix(cfg.Seed, orgID). 336 KB per tenant measured, bounded by MaxOrgs (default 256 → 84 MB). AML_ANOMALY=live leaves shadow; until then the model scores, learns and reports what it would have alerted on, changing nothing.

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 Replay a candidate rule over history (dry run)
GET /v1/aml/anomaly Model state: appetite, inventory, threshold, stated vs realised rate, refusals, blind features, below-the-line sample
POST /v1/aml/anomaly/test Score a candidate against the tenant's model, learning nothing
POST /v1/aml/cases/{id}/resolve Close a case against a retained assessment
POST /v1/aml/relationships Open a business relationship
POST /v1/aml/relationships/{id}/close End one, starting the retention clocks
POST /v1/aml/relationships/search Art. 78 five-year lookback by party
POST /v1/aml/sanctions/search Search sanctions lists by name
GET /v1/aml/health Health check, 503 when records cannot be kept
GET /v1/aml/config Brand identity of the request's Host: brand, display name, issuer, domain

Every route resolves its tenant through Handler.Identity, and /v1/aml/config is the one exception: it names the issuer a caller needs in order to obtain a token, so requiring one would be a lock whose key is behind it.

Two identities, one seam. api.IAMIdentity(api.JWKS(ttl, stale)) takes the tenant from the owner claim of a bearer token, verified against the JWKS of the issuer belonging to the request's own Host — so a Lux token does not authenticate on a Zoo host even where one in-cluster IAM publishes both brands' signing certificates. api.TrustedProxyHeader("X-Org-Id") takes it from the header a gateway writes from that same claim, which is sound only where the gateway authenticates the caller, strips any client-supplied copy, and is the only route to this service.

White-label

pkg/brand maps a brand id, and a request Host, to that brand's public identity: its OIDC issuer and the domains it serves on. It is a copy of the fleet's canonical registry (HIP-0111, hanzoai/cloud brand/brand.go) kept as a leaf, so one amld answers as Lux on lux.network, as Zoo on zoo.ngo and zoo.cloud, as Hanzo on hanzo.ai, and as Pars on pars.network — brand, console identity and trusted issuer all from the Host. An unrecognised Host falls back to Hanzo.

Org names are unique within an issuer, not across issuers. One deployment serving more than one brand from one record plane therefore needs its store scoped by brand as well as by org; a deployment per brand needs nothing further.

Records, tokenisation, and the sandbox

Three obligations, three packages, one join in pkg/api/records.go.

Retention (pkg/retention) — HIP-0518 §9. Five years from the end of the relationship, from the occasional transaction, or from the date of refusal (AMLR Art. 77(3)); a blocked transaction is therefore retained as a refusal with the rules that refused it, running from its own clock. A record retained inside a relationship has no expiry until Close cascades the clock to it. Extensions are case-by-case, capped at five further years, refused without a reason and a decider. Reads take a Purpose from a closed set, because retained personal data may be processed only to prevent ML/TF (AMLD4 Art. 41(2)).

Deletion on expiry against no redaction — the unit of disposal is the whole record, never a field. Nothing in the package removes, masks or rewrites part of a retained record; reads hand out deep copies so a reader cannot either. At expiry the record is destroyed in full with every index entry that referenced it, and Dispose verifies its own post-conditions before reporting a count — a run that cannot prove what it destroyed reports the error and nothing else. It also refuses a date the caller's clock has not reached, so a clock lie cannot bring destruction forward.

The five-year lookback (Art. 78) is answered from a per-party index, not a scan. Answer.Examined is the evidence: it counts one party's records and does not grow with the ledger.

Tokenisation (pkg/token) — one root from KMS, per-org keys by HKDF with the org as salt, per-domain keys by HKDF info. Two operations: a deterministic pseudonym, which is what the ledger indexes and correlates on, and an AES-256-GCM seal bound to the org and the record slot, which is what holds the record body. It is deliberately not one-way: a hash-only design can neither reconstruct a transaction (MLR reg. 40(2)(b)) nor re-screen a customer base against a new designation (EBA/GL/2024/15 §4.1.4), so it would fail the obligations it is protecting. Correlation is per org and never across orgs — the same customer in two orgs is two unrelated keys, so a cross-tenant join is not computable.

The sandbox (pkg/replay) — JMLSG 5.7.18 requires new typologies to be tested before live activation, and FCG 3.2.5A requires a retirement to be justified against the outgoing rule's performance. /v1/aml/rules/test replays a candidate over the org's retained transactions through the engine's own evaluator, and reports how many alerts it would raise, on what, and the added/dropped/kept difference against the rule it would replace. An empty history is refused rather than reported as zero alerts, and the false-positive proportion and intelligence-value are absent rather than zero when nothing was judged. It reaches the engine through a one-method interface and history through another, so it has nothing it could write to.

AML_TOKEN_KEY carries the KMS-held root — 32 bytes or more, hex encoded. There is no default. Without it an instance reports itself unfit on /v1/aml/health and refuses to ingest, because a transaction that cannot be recorded must not be processed.

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

  • Hanzo Tasks durable workflows (sanctions-refresh, case-automation, backtest)
  • Base realtime subscription for live transaction monitoring
  • Full OFAC/UN/EU/HMT list refresh automation
  • Base collection persistence (current: in-memory stores, the retention ledger included). A retention ledger that does not survive a restart is a record-keeping breach, so this is the first thing to close, and it is what makes the five-year lookback and the disposal cron mean anything in production.
  • Per-org KMS key material. token.Source already takes the org, so this is a different Source and not a different design; today one root is derived per org by HKDF, which means no cross-org correlation but one blast radius.
  • Exposing Ledger.Extend over HTTP. The cap and the refusals are implemented and tested; there is no route, because the decider's identity would be client-asserted (see below).
  • Tokenising the operational transaction store. pkg/engine/basestore.go still writes user_id, counterparty and ip_address in the clear for its aggregate queries; the retention ledger holds pseudonyms and sealed bodies, so the two planes do not agree yet.
  • Attribution. Every decider (by on a resolution) is client-asserted: the service has a tenant identity but no user identity, so "who dismissed this alert" is not authenticated.
  • cases.AutoClose closes low-severity cases on inactivity without a retained assessment — the one path that closes a case without a recorded decision. Needs a compliance decision: escalate instead of closing, or record a system rationale.

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:

  1. Never write a hash sum.golang.org does not corroborate. The log is the authority; if go.sum disagrees with it, go.sum is 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]
    
  2. go mod verify does not check go.sum. It compares the extracted module against the hash recorded in the local module cache, so it reports "all modules verified" while a go.sum entry that matches nothing sits right next to it. Only a download can catch that — on a clean GOMODCACHE, since a cache entry fetched from the git host keeps serving those bits.

  3. Prefer the lowest unmoved patch successor (x.y.z -> x.y.z+1). Never jump a major; never go above v1.x.x.

  4. 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/pq v1.0.3 moved from e16b004d to 90d2223e, but the two commits have byte-identical trees, so the zip hash never changed. The proxy, the log, and the git host all report h1:pFlQm1+5...; go.sum had recorded h1:ksw1dmfT..., which corresponds to nothing obtainable and made a cold-cache build fail outright. go.sum now carries the log hash. No successor tag is needed.
  • hanzoai/base v1.1.0 moved from fe516fdc to a0b4a9f3, and the content did change — in exactly three files, README.md, docs/NETWORK.md, and docs/NETWORK_RED_REVIEW.md. No Go source and no go.mod differs, so the two trees compile identically. go.sum carries the log hash (h1:Mi4yRgR1..., the fe516fdc tree). A machine whose module cache already holds the a0b4a9f3 bits 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 cut v1.1.1 at a0b4a9f3 so 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