mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
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]>
84 lines
2.8 KiB
Go
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)
|
|
}
|
|
}
|