Files
aml/pkg/api/screening_input_test.go
zeekayandHanzo Dev 9f098d396c 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]>
2026-07-29 12:03:32 -07:00

84 lines
2.8 KiB
Go

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)
}
}