api: do not screen a customer identifier as a name

Ingest defaulted the customer's name from its identifier, so an opaque database
key was screened against the designation lists. Measured against the live lists,
"cust-1" scored 0.902 against "BRANCH 1 OF THE SHIRAZ REVOLUTIONARY COU" — above
the 0.85 reporting threshold — and an ordinary $100 payment came back blocked.

A handle cannot produce a true positive against a list of people's names, so every
match it yields is false, and this one was the most expensive kind of false: the
rule's action is block, so the engine declined a legitimate payment and named a
sanctions hit as the reason.

The customer now comes from the caller, who is the only party that knows it. With
no name supplied the customer-screening rule's own guard holds and the rule does
not fire, which is the honest outcome — a customer whose name this service has
never been given has not been screened, and the way to screen them is to send the
name rather than to invent one from a key. The catalog already publishes this as a
gap.

Measured end to end on a benign $100 payment, against 31,338 loaded designations:
six alerts at a saturated score before this arc, then two, then blocked, and now
allow / score 0 / no alerts. A designated party still matches — "Vladimir Putin"
returns 28 hits, exact on OFAC, EU and OFSI.

Also here: a second defect this exposed and did not fix. The matcher scoring a
six-character single token at 0.902 against a six-word organisation name is wrong
on its own terms, whatever is fed to it. The single-token asymmetry does not hold
on this path. Left as a named finding rather than a hurried threshold change,
because raising the threshold to hide it would suppress genuine transliterations,
which score lower than this false positive did.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-29 12:03:32 -07:00
co-authored by Hanzo Dev
parent 3ae6be949e
commit 9f098d396c
3 changed files with 111 additions and 6 deletions
+4
View File
@@ -162,9 +162,13 @@ func TestHealthReportsUnfitWithoutKeyMaterial(t *testing.T) {
func TestIngestRetainsATransactionThatCanBeReconstructed(t *testing.T) {
h := plane(t)
// The customer's name is supplied by the caller, not derived from the
// identifier: a customer id is an opaque handle, and screening one as a name
// produces false positives with no possible true positive.
rec := ingest(t, h, map[string]any{
"id": "tx-1", "user_id": "ivan-petrov-42", "account_id": "GB33BUKB20201555555555",
"counterparty": "acme-ltd", "notional": 25000, "currency": "USD", "symbol": "BTC",
"entity": map[string]any{"id": "ivan-petrov-42", "name": "Ivan Petrov"},
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200: %s", rec.Code, rec.Body.String())
+24 -6
View File
@@ -302,6 +302,10 @@ func (h *Handler) ingestTransaction() func(e *core.RequestEvent) error {
var in struct {
types.Transaction
Relationship string `json:"relationship"`
// Entity is the customer this transaction belongs to. It is supplied by
// the caller because only the caller knows it: a name cannot be derived
// from an identifier, and customer screening has no input without one.
Entity types.Entity `json:"entity"`
}
if err := json.NewDecoder(e.Request.Body).Decode(&in); err != nil {
return fail(e, http.StatusBadRequest, "invalid request body")
@@ -347,12 +351,26 @@ func (h *Handler) ingestTransaction() func(e *core.RequestEvent) error {
}
}
// Resolve entity — for now use a minimal entity from the tx.
entity := types.Entity{
ID: tx.UserID,
OrgID: orgID,
Name: tx.UserID,
EntityType: types.EntityUser,
// The customer this transaction belongs to.
//
// Name is deliberately NOT defaulted from the identifier. A customer id is an
// opaque handle, and screening one as though it were a person's name cannot
// produce a true positive while readily producing false ones: "cust-1"
// scored 0.902 against "BRANCH 1 OF THE SHIRAZ REVOLUTIONARY COU" — over the
// reporting threshold — and blocked an ordinary payment.
//
// With no name supplied the customer-screening rule's guard (Entity.Name !=
// "") holds and the rule does not fire. That is the honest outcome: a
// customer whose name this service has never been given has not been
// screened, and the way to screen them is to send the name, not to invent one
// from a database key. GET /v1/aml/catalog carries the gap.
entity := in.Entity
entity.OrgID = orgID
if entity.ID == "" {
entity.ID = tx.UserID
}
if entity.EntityType == "" {
entity.EntityType = types.EntityUser
}
vault, err := h.vault(orgID)
+83
View File
@@ -0,0 +1,83 @@
package api
import (
"encoding/json"
"net/http"
"testing"
"github.com/luxfi/aml/pkg/retention"
)
// A customer identifier must never be screened as a name.
//
// The defect this guards was the most expensive kind: ingest defaulted the
// customer's name from its identifier, so "cust-1" was screened against the
// designation lists, scored 0.902 against "BRANCH 1 OF THE SHIRAZ REVOLUTIONARY
// COU" — above the reporting threshold — and an ordinary $100 payment came back
// blocked. An opaque handle cannot produce a true positive against a list of
// people's names, so every match it produces is false.
//
// With no name supplied, the customer-screening rule's own guard holds and the
// rule does not fire. That is the honest outcome: a customer whose name this
// service has never been given has not been screened.
func TestIdentifierIsNotScreenedAsAName(t *testing.T) {
h := plane(t)
rec := ingest(t, h, map[string]any{
"id": "tx-id-only", "user_id": "cust-1", "account_id": "acct-1",
"notional": 100, "currency": "USD",
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200: %s", rec.Code, rec.Body.String())
}
// The retained record must not carry a party derived from the identifier as
// though it were a name — indexing one invites the same mistake on the read
// side, where the lookback would report a name that was never given.
var out struct {
Record string `json:"record"`
Action string `json:"action"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.Action == "block" {
t.Fatalf("an ordinary payment for a customer with no name on file was blocked")
}
}
// When the caller does supply a name, it is used. Refusing to invent a name must
// not become a refusal to screen one that was given.
func TestSuppliedNameIsScreened(t *testing.T) {
h := plane(t)
rec := ingest(t, h, map[string]any{
"id": "tx-named", "user_id": "cust-2", "account_id": "acct-2",
"notional": 100, "currency": "USD",
"entity": map[string]any{"id": "cust-2", "name": "Ordinary Customer"},
})
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want 200: %s", rec.Code, rec.Body.String())
}
var out struct {
Record string `json:"record"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v", err)
}
if out.Record == "" {
t.Fatal("ingest answered without naming the record it retained")
}
// The name reaches the record plane, which is what makes the customer findable
// on a five-year lookback by name rather than only by key.
record, err := h.Records.Get(retention.PurposeInvestigation, acme, out.Record)
if err != nil {
t.Fatalf("Get: %v", err)
}
if len(record.Parties) < 3 {
t.Errorf("parties = %v; a supplied name should be indexed alongside the subject and account", record.Parties)
}
}