Files
aml/pkg/reference/reference.go
T
zeekayandHanzo Dev 039fa7e3d6 engine: a rule is admitted only if its evidence exists
The previous evaluator registered a stub for every term the language offered.
Absent a real implementation, sanctions screening answered false, geographic
matching answered true, the local hour answered noon, travel-rule completeness
answered complete, and chain risk answered zero. Six of the terms were later
replaced by store-backed versions; eleven never were, which left thirteen of the
twenty-three shipped rules structurally unable to fire. Measured on the shipped
code: a 50,000 dollar crypto payment at 3 a.m. from a politically exposed
customer in a listed jurisdiction, to a fresh address, naming a designated
person, raised four alerts out of twenty-three.

There are now no stubs. Terms are methods on the evaluation scope, each term is
mapped to the provider that answers it, and a rule naming a term whose provider
is absent is refused when the rule set is installed. The refusal names the term.
A rule set containing one such rule installs nothing, because a partially
installed set is a control surface nobody can describe.

Rules must yield a boolean. The previous evaluator accepted a number and read it
as truthy, so a velocity rule written as a bare sum fired on any activity at all.

Evidence failures at evaluation time — an unknown currency, an absent subject, an
unreachable store — are recorded on the alert and routed to review rather than
cleared, so an outage cannot become an approval.

Windows are fetched once per subject and lookback for the whole rule set rather
than once per rule.

Aggregation is over the transaction's own timestamp, not the row's insertion
time, and history queries are parameterised and scoped to the tenant. The
previous store interpolated the customer identifier into the filter string and
filtered by neither organisation nor transaction time.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 10:55:48 -07:00

124 lines
4.5 KiB
Go

// Package reference holds the published data a rule needs but cannot derive:
// currency conversion rates and jurisdiction risk listings.
//
// Both carry the date they were current at, and both refuse to answer rather than
// guess. That is the difference between a control and the appearance of one: a
// conversion table that passes an unknown currency through unchanged moves every
// threshold by the exchange rate, and a jurisdiction list that answers "not
// listed" because it was never loaded is indistinguishable from a clean world.
package reference
import (
"context"
"fmt"
"strings"
"time"
)
// Rates converts amounts into USD from a table current at a stated instant.
type Rates struct {
// AsOf is when the table was current. It is required: a rate with no date
// cannot be assessed for staleness, and a stale rate silently moves every
// threshold expressed in USD.
AsOf time.Time
// USDPer maps an ISO 4217 code to the USD value of one unit.
USDPer map[string]float64
// MaxAge is how old the table may be before conversion fails. Zero means no
// limit, which is appropriate only where every amount is already USD.
MaxAge time.Duration
// Now supplies the current instant; nil means the wall clock.
Now func() time.Time
}
// USD converts an amount to USD.
//
// An unknown currency is an error. Returning the amount unchanged — the
// conservative-looking choice — is not conservative: a currency worth more than a
// dollar is understated, so 10,000 units of a currency worth three dollars is
// assessed as 10,000 and sits below a 10,000-dollar reporting threshold that it
// is in truth three times over.
func (r Rates) USD(_ context.Context, amount float64, currency string) (float64, error) {
code := strings.ToUpper(strings.TrimSpace(currency))
if code == "" || code == "USD" {
return amount, nil
}
rate, ok := r.USDPer[code]
if !ok {
return 0, fmt.Errorf("reference: no rate for currency %q, so its USD value is unknown", code)
}
if rate <= 0 {
return 0, fmt.Errorf("reference: rate for %q is %v, which is not a price", code, rate)
}
if r.MaxAge > 0 {
if age := r.now().Sub(r.AsOf); age > r.MaxAge {
return 0, fmt.Errorf("reference: rate table is %s old, older than the %s limit", age.Truncate(time.Hour), r.MaxAge)
}
}
return amount * rate, nil
}
func (r Rates) now() time.Time {
if r.Now != nil {
return r.Now()
}
return time.Now().UTC()
}
// Jurisdictions is a published listing of higher-risk countries.
//
// The two tiers mirror what standard-setters publish: countries for which
// countermeasures are called for, and countries under increased monitoring. They
// are separate because the required response differs, and collapsing them into one
// "risky" flag loses the distinction a rule needs to choose between blocking and
// enhanced review.
//
// Membership is not compiled in. The lists change several times a year, a
// compiled-in copy is wrong within months of a release, and a wrong list in a
// compliance product is worse than an absent one because it is trusted. The
// operator loads the current lists and states the date they were current at.
type Jurisdictions struct {
AsOf time.Time
// Action holds the codes calling for countermeasures.
Action []string
// Monitoring holds the codes under increased monitoring.
Monitoring []string
}
// Jurisdiction returns the tier for an ISO 3166-1 alpha-2 code: the empty string
// if the country is on neither list.
//
// It returns an error when no listing has been loaded. An empty listing would
// answer "not listed" for every country, which reads as a world with no high-risk
// jurisdictions in it, and no operator would notice.
func (j Jurisdictions) Jurisdiction(code string) (string, error) {
if len(j.Action) == 0 && len(j.Monitoring) == 0 {
return "", fmt.Errorf("reference: no jurisdiction listing loaded, so no country can be assessed")
}
if j.AsOf.IsZero() {
return "", fmt.Errorf("reference: jurisdiction listing has no date, so its currency cannot be assessed")
}
want := strings.ToUpper(strings.TrimSpace(code))
if want == "" {
return "", nil
}
for _, c := range j.Action {
if strings.EqualFold(strings.TrimSpace(c), want) {
return "action", nil
}
}
for _, c := range j.Monitoring {
if strings.EqualFold(strings.TrimSpace(c), want) {
return "monitoring", nil
}
}
return "", nil
}
// Age reports how old the listing is, for a readiness check.
func (j Jurisdictions) Age(now time.Time) time.Duration {
if j.AsOf.IsZero() {
return 0
}
return now.Sub(j.AsOf)
}