standard, history, measure: citations and pure behavioural measures

standard models a citation to a published requirement: authority, document,
locator, URL. A control that cannot name the standard it implements cannot be
defended to an examiner, so the library rejects an incomplete citation.

history offers one query — the subject's event window — so the tenant filter
lives in one place and every measure becomes a pure function of the window.

measure holds those functions. Structuring is the aggregation of sub-threshold
transactions reaching the threshold, not a band check on one amount. Currency
reporting aggregates a calendar day in the institution's zone, not a rolling
24 hours. Deviation uses the median and median absolute deviation because a
monitoring baseline is built from data that already contains the outliers being
looked for, and one past outlier inflates a standard deviation enough to hide
the next one.

15 of 15 mutations to the measure guards are killed by the suite.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-28 23:18:01 -07:00
co-authored by Hanzo Dev
parent a6d95ca678
commit 3118a7dbb2
4 changed files with 722 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
// Package history retrieves a subject's past transactions.
//
// The package offers exactly one query: the window of events for a subject over
// a lookback period. Every behavioural measure the rule library needs — counts,
// sums, distinct counterparties, sub-threshold aggregation, dormancy, in-out
// alternation, deviation from a baseline — is a pure function of that window,
// computed in the measure package. One query keeps the tenant filter in one
// place and turns the measures into ordinary testable functions.
package history
import (
"context"
"time"
)
// Subject kinds. A subject is whoever the aggregation is grouped by: the
// customer, one of their accounts, a counterparty, or a device or address they
// transacted from. Grouping by device and address is what surfaces several
// nominally-unrelated customers acting as one.
const (
SubjectUser = "user"
SubjectAccount = "account"
SubjectCounterparty = "counterparty"
SubjectDevice = "device"
SubjectAddress = "address"
)
// Directions. Direction is required for in-out and pass-through detection:
// the pattern is defined by funds leaving shortly after they arrive, which is
// invisible if the store records only magnitude.
const (
In = "in"
Out = "out"
)
// Subject identifies whose history to aggregate. OrgID is part of the identity,
// not a filter applied afterwards, so a query cannot accidentally aggregate
// across tenants.
type Subject struct {
OrgID string
Kind string
ID string
}
// Event is one past transaction, reduced to the fields the measures read.
// USD holds the value converted at ingestion time, because a threshold measured
// today over a window of historical events must not silently move when a rate
// moves.
type Event struct {
ID string
At time.Time
USD float64
Currency string
Direction string
Counterparty string
Account string
Device string
Address string
Jurisdiction string
Symbol string
}
// Store retrieves event windows.
//
// Window returns the subject's events with At in [now-lookback, now], most
// recent first. Implementations must scope to Subject.OrgID and must return an
// error rather than a partial window: a measure computed over a truncated
// window silently under-reports, which is the failure mode a monitoring system
// can least afford.
type Store interface {
Window(ctx context.Context, subj Subject, lookback time.Duration) ([]Event, error)
}
+235
View File
@@ -0,0 +1,235 @@
// Package measure computes behavioural measures over a window of events.
//
// Every function here is pure: it takes the events and returns a number or a
// boolean. Nothing reaches a database, a clock, or a network. That is what makes
// the detection logic testable at the level a reviewer cares about — "given
// these five deposits, does structuring fire?" — instead of only through the
// storage layer.
package measure
import (
"math"
"sort"
"time"
"github.com/luxfi/aml/pkg/history"
)
// Count is the number of events in the window.
func Count(evs []history.Event) int { return len(evs) }
// Sum is the total USD value of the events in the window.
func Sum(evs []history.Event) float64 {
var t float64
for _, e := range evs {
t += e.USD
}
return t
}
// Max is the largest single USD value in the window, or zero if empty.
func Max(evs []history.Event) float64 {
var m float64
for _, e := range evs {
if e.USD > m {
m = e.USD
}
}
return m
}
// Distinct counts the distinct non-empty values of a key over the window.
// Passing a counterparty key answers "how many different parties", which
// separates one large relationship from a fan-out across many.
func Distinct(evs []history.Event, key func(history.Event) string) int {
seen := make(map[string]struct{}, len(evs))
for _, e := range evs {
if v := key(e); v != "" {
seen[v] = struct{}{}
}
}
return len(seen)
}
// Sum of the events in one direction.
func SumDirection(evs []history.Event, dir string) float64 {
var t float64
for _, e := range evs {
if e.Direction == dir {
t += e.USD
}
}
return t
}
// Structured reports whether the window holds a set of transactions each below
// the reporting threshold that together reach or exceed it.
//
// This is the detectable signature of the conduct prohibited by 31 USC 5324 and
// 31 CFR 1010.314: breaking an amount that would be reportable into several
// amounts that individually are not. The predicate deliberately requires at
// least two sub-threshold transactions — a single reportable transaction is a
// filing obligation, not structuring — and ignores transactions at or above the
// threshold, because those were reported and so were not evaded.
//
// min is the count of sub-threshold transactions required before the pattern is
// reported, so an institution can tune away the noise of ordinary repeat
// activity without changing the definition. Values below 2 have no effect: one
// transaction below the threshold cannot by itself reach the threshold, so the
// aggregate test already excludes it.
func Structured(evs []history.Event, threshold float64, min int) bool {
if threshold <= 0 {
return false
}
var n int
var total float64
for _, e := range evs {
if e.USD > 0 && e.USD < threshold {
n++
total += e.USD
}
}
return n >= min && total >= threshold
}
// Near reports whether a value sits in the band immediately below a threshold.
// Amounts just under a reporting threshold are a published red-flag indicator
// in their own right, independent of whether they aggregate over the threshold.
// band is a fraction of the threshold: 0.1 covers 9,000-10,000 at 10,000. A band
// wider than the whole threshold is rejected rather than clamped, because it
// would silently turn the indicator into "any amount below the threshold".
func Near(value, threshold, band float64) bool {
if band <= 0 || band > 1 {
return false
}
return value >= threshold*(1-band) && value < threshold
}
// Day totals the events falling on the same calendar day as when, in loc.
//
// A rolling lookback and a calendar day are different questions, and a currency
// reporting threshold asks the calendar one: 31 CFR 1010.311 aggregates multiple
// currency transactions totalling more than the threshold in one business day. A
// 24-hour rolling window spans two business days, so it both invents aggregates
// that no single day contains and misses same-day activity that has already
// fallen out of the window. loc is the institution's business day, which is not
// necessarily the server's.
func Day(evs []history.Event, loc *time.Location, when time.Time) float64 {
if loc == nil {
loc = time.UTC
}
day := when.In(loc).Format(time.DateOnly)
var t float64
for _, e := range evs {
if e.At.In(loc).Format(time.DateOnly) == day {
t += e.USD
}
}
return t
}
// InOut reports whether value arrived and left again within the window, keeping
// no more than residue behind.
//
// Funds passing straight through an account is the layering signature described
// in FATF typology work: the account is a conduit, not a store of value. The
// test is that both directions are present, that the smaller of the two sides
// is at least min, and that the retained fraction is no greater than residue.
func InOut(evs []history.Event, min, residue float64) bool {
in := SumDirection(evs, history.In)
out := SumDirection(evs, history.Out)
if in < min || out < min {
return false
}
kept := math.Abs(in-out) / math.Max(in, out)
return kept <= residue
}
// Dormancy is the time between the two most recent events in the window, which
// is the gap the newest event broke. It returns zero when the window holds
// fewer than two events, because a gap needs two ends.
//
// A long-idle account that suddenly transacts is a published indicator; the
// measure reports the gap and leaves the threshold to the rule.
func Dormancy(evs []history.Event) time.Duration {
if len(evs) < 2 {
return 0
}
at := make([]time.Time, 0, len(evs))
for _, e := range evs {
at = append(at, e.At)
}
sort.Slice(at, func(i, j int) bool { return at[i].After(at[j]) })
return at[0].Sub(at[1])
}
// Round reports the fraction of the window's events whose value is an exact
// multiple of unit. Genuine commercial amounts carry odd cents and odd units;
// a book of perfectly round amounts is a published indicator of invented
// invoicing and of value moved for its own sake rather than for a trade.
func Round(evs []history.Event, unit float64) float64 {
if unit <= 0 || len(evs) == 0 {
return 0
}
var n int
for _, e := range evs {
if e.USD > 0 && math.Mod(e.USD, unit) == 0 {
n++
}
}
return float64(n) / float64(len(evs))
}
// Deviation is the robust modified z-score of value against the window as
// baseline: 0.6745 * (value - median) / medianAbsoluteDeviation.
//
// The median and the median absolute deviation are used rather than the mean and
// the standard deviation because a monitoring baseline is built from data that
// already contains the outliers being looked for. One large past transaction
// inflates a standard deviation enough to hide the next one, whereas the median
// absolute deviation is unmoved by up to half the sample. The 0.6745 constant
// scales the median absolute deviation onto the standard-deviation scale for a
// normal distribution, so a threshold of 3.5 carries its conventional meaning.
//
// Deviation returns zero when the window is too small to carry a baseline, or
// when the baseline has no spread at all. Both cases mean "no opinion", and a
// measure with no opinion must not manufacture one.
func Deviation(evs []history.Event, value float64, min int) float64 {
if min < 4 {
min = 4
}
if len(evs) < min {
return 0
}
vals := make([]float64, 0, len(evs))
for _, e := range evs {
vals = append(vals, e.USD)
}
med := median(vals)
dev := make([]float64, len(vals))
for i, v := range vals {
dev[i] = math.Abs(v - med)
}
mad := median(dev)
if mad == 0 {
return 0
}
return 0.6745 * (value - med) / mad
}
// median returns the median of vals. It sorts a copy, because a measure must
// not reorder its caller's slice.
func median(vals []float64) float64 {
if len(vals) == 0 {
return 0
}
s := make([]float64, len(vals))
copy(s, vals)
sort.Float64s(s)
mid := len(s) / 2
if len(s)%2 == 1 {
return s[mid]
}
return (s[mid-1] + s[mid]) / 2
}
+346
View File
@@ -0,0 +1,346 @@
package measure
import (
"math"
"testing"
"time"
"github.com/luxfi/aml/pkg/history"
)
// at builds an event n hours before a fixed reference instant.
func at(hoursAgo int, usd float64, dir string) history.Event {
ref := time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC)
return history.Event{
At: ref.Add(-time.Duration(hoursAgo) * time.Hour),
USD: usd,
Direction: dir,
}
}
func TestStructuredDetectsSplitDeposits(t *testing.T) {
// Five deposits of 2,500 total 12,500 with no single one reportable.
// This is the case a point-in-time threshold check cannot see.
evs := []history.Event{
at(1, 2500, history.In), at(3, 2500, history.In), at(5, 2500, history.In),
at(7, 2500, history.In), at(9, 2500, history.In),
}
if !Structured(evs, 10000, 2) {
t.Fatal("five sub-threshold deposits totalling 12,500 must report as structured")
}
}
func TestStructuredIgnoresReportedTransaction(t *testing.T) {
// A single 12,000 transaction is reportable and was reported. Aggregating it
// into a structuring finding would accuse the customer of evading a
// requirement the transaction actually triggered.
evs := []history.Event{at(1, 12000, history.In)}
if Structured(evs, 10000, 2) {
t.Fatal("one above-threshold transaction is a filing obligation, not structuring")
}
}
func TestStructuredExcludesReportedAmountFromTheAggregate(t *testing.T) {
// One reported 12,000 alongside two small deposits. The reported amount must
// not be counted towards the evaded total: on its own the sub-threshold
// activity is 2,000, nowhere near the threshold. Folding the reported 12,000
// back in would manufacture a structuring finding out of a transaction that
// was declared.
evs := []history.Event{
at(1, 12000, history.In), at(3, 1000, history.In), at(5, 1000, history.In),
}
if Structured(evs, 10000, 2) {
t.Fatal("a declared transaction must not be counted towards an evaded aggregate")
}
}
func TestStructuredRejectsUnconfiguredThreshold(t *testing.T) {
// The realistic failure: an operator adds a structuring rule and leaves the
// threshold and minimum count unset, so both arrive as zero. With no guard,
// nothing is sub-threshold so the count is zero, the aggregate is zero, and
// both "0 >= 0" tests pass — the rule reports every transaction ever seen,
// including an empty window. An unconfigured control must detect nothing, not
// everything.
evs := []history.Event{at(1, 5000, history.In), at(2, 5000, history.In)}
if Structured(evs, 0, 0) {
t.Fatal("an unconfigured threshold must report nothing, not everything")
}
if Structured(nil, 0, 0) {
t.Fatal("an unconfigured threshold over an empty window must report nothing")
}
if Structured(evs, -1, 2) {
t.Fatal("a negative threshold must report nothing")
}
}
func TestStructuredRequiresAggregateToReachThreshold(t *testing.T) {
// Two small deposits that do not add up to the threshold are ordinary.
evs := []history.Event{at(1, 500, history.In), at(2, 400, history.In)}
if Structured(evs, 10000, 2) {
t.Fatal("sub-threshold deposits below the threshold in aggregate must not report")
}
}
func TestStructuredRespectsMinimumCount(t *testing.T) {
evs := []history.Event{at(1, 9000, history.In), at(2, 9000, history.In)}
if !Structured(evs, 10000, 2) {
t.Fatal("two sub-threshold deposits over the threshold in aggregate must report at min=2")
}
if Structured(evs, 10000, 3) {
t.Fatal("min=3 must suppress a two-transaction pattern")
}
}
func TestStructuredCannotFireOnOneTransaction(t *testing.T) {
// Even asking for min=1, one transaction below the threshold cannot reach the
// threshold, so the aggregate test alone rules out single-transaction
// structuring. This is why no separate floor on min is needed.
evs := []history.Event{at(1, 9999, history.In)}
if Structured(evs, 10000, 1) {
t.Fatal("a single sub-threshold transaction can never aggregate to the threshold")
}
}
func TestNearBand(t *testing.T) {
if !Near(9500, 10000, 0.1) {
t.Fatal("9,500 is inside the 10 percent band below 10,000")
}
if Near(8000, 10000, 0.1) {
t.Fatal("8,000 is outside the 10 percent band below 10,000")
}
if Near(10000, 10000, 0.1) {
t.Fatal("the threshold itself is reportable, not near")
}
}
func TestNearRejectsBandWiderThanTheThreshold(t *testing.T) {
// A band above 1 puts the lower bound below zero, which would report every
// amount under the threshold as sitting just beneath it — the indicator would
// fire on a 1-dollar payment.
if Near(1, 10000, 2) {
t.Fatal("a band wider than the threshold must be rejected, not treated as unbounded")
}
if Near(9500, 10000, 1.0001) {
t.Fatal("a band above 1 must be rejected")
}
}
func TestDayAggregatesCalendarNotRolling(t *testing.T) {
// 6,000 at 23:00 on the 9th and 6,000 at 01:00 on the 10th are two hours
// apart but fall in different calendar days. A rolling 24-hour sum reports
// 12,000 and would file a currency report; the calendar-day total for the
// 10th is 6,000 and no report is due.
evs := []history.Event{
{At: time.Date(2026, 3, 9, 23, 0, 0, 0, time.UTC), USD: 6000},
{At: time.Date(2026, 3, 10, 1, 0, 0, 0, time.UTC), USD: 6000},
}
if got := Sum(evs); got != 12000 {
t.Fatalf("rolling sum = %v, want 12000", got)
}
when := time.Date(2026, 3, 10, 1, 0, 0, 0, time.UTC)
if got := Day(evs, time.UTC, when); got != 6000 {
t.Fatalf("calendar day total = %v, want 6000 — the two deposits are on different days", got)
}
}
func TestDayTotalsOnlyTheNamedDay(t *testing.T) {
evs := []history.Event{
{At: time.Date(2026, 3, 8, 10, 0, 0, 0, time.UTC), USD: 4000},
{At: time.Date(2026, 3, 9, 10, 0, 0, 0, time.UTC), USD: 7000},
{At: time.Date(2026, 3, 9, 14, 0, 0, 0, time.UTC), USD: 5000},
}
when := time.Date(2026, 3, 9, 14, 0, 0, 0, time.UTC)
if got := Day(evs, time.UTC, when); got != 12000 {
t.Fatalf("total for 9 March = %v, want 12000", got)
}
when = time.Date(2026, 3, 8, 10, 0, 0, 0, time.UTC)
if got := Day(evs, time.UTC, when); got != 4000 {
t.Fatalf("total for 8 March = %v, want 4000 — other days must not be counted", got)
}
}
func TestDayHonoursLocation(t *testing.T) {
// 22:00 and 02:00 UTC straddle midnight in UTC but are the same calendar day
// in a UTC-5 zone. The business day belongs to the institution, not the
// server, and getting this wrong moves a reporting obligation by a day.
evs := []history.Event{
{At: time.Date(2026, 3, 9, 22, 0, 0, 0, time.UTC), USD: 6000},
{At: time.Date(2026, 3, 10, 2, 0, 0, 0, time.UTC), USD: 6000},
}
when := time.Date(2026, 3, 10, 2, 0, 0, 0, time.UTC)
if got := Day(evs, time.UTC, when); got != 6000 {
t.Fatalf("UTC day total = %v, want 6000", got)
}
west := time.FixedZone("UTC-5", -5*3600)
if got := Day(evs, west, when); got != 12000 {
t.Fatalf("UTC-5 day total = %v, want 12000 — both fall on 9 March locally", got)
}
}
func TestDayDefaultsToUTC(t *testing.T) {
evs := []history.Event{{At: time.Date(2026, 3, 9, 10, 0, 0, 0, time.UTC), USD: 5000}}
when := time.Date(2026, 3, 9, 12, 0, 0, 0, time.UTC)
if got := Day(evs, nil, when); got != 5000 {
t.Fatalf("nil location must fall back to UTC, got %v", got)
}
}
func TestInOutDetectsPassThrough(t *testing.T) {
evs := []history.Event{at(2, 50000, history.In), at(1, 49000, history.Out)}
if !InOut(evs, 10000, 0.05) {
t.Fatal("50,000 in and 49,000 out retains 2 percent and must report as pass-through")
}
}
func TestInOutIgnoresAccumulation(t *testing.T) {
// Money in with nothing going out is accumulation, not layering.
evs := []history.Event{at(2, 50000, history.In), at(1, 40000, history.In)}
if InOut(evs, 10000, 0.05) {
t.Fatal("deposits with no withdrawal must not report as pass-through")
}
}
func TestInOutIgnoresRetainedFunds(t *testing.T) {
// Half the money stayed. That is a customer using an account.
evs := []history.Event{at(2, 50000, history.In), at(1, 25000, history.Out)}
if InOut(evs, 10000, 0.05) {
t.Fatal("retaining half the inflow must not report as pass-through")
}
}
func TestInOutIgnoresSmallAmounts(t *testing.T) {
evs := []history.Event{at(2, 100, history.In), at(1, 99, history.Out)}
if InOut(evs, 10000, 0.05) {
t.Fatal("amounts below the minimum must not report")
}
}
func TestDormancyMeasuresTheGapBroken(t *testing.T) {
evs := []history.Event{at(0, 40000, history.In), at(24*200, 100, history.In)}
got := Dormancy(evs)
if want := 200 * 24 * time.Hour; got != want {
t.Fatalf("dormancy = %v, want %v", got, want)
}
}
func TestDormancyNeedsTwoEvents(t *testing.T) {
if got := Dormancy([]history.Event{at(0, 100, history.In)}); got != 0 {
t.Fatalf("dormancy with one event = %v, want 0 — a gap needs two ends", got)
}
}
func TestDormancyDoesNotReorderCallerSlice(t *testing.T) {
evs := []history.Event{at(0, 1, history.In), at(100, 2, history.In)}
first := evs[0].USD
Dormancy(evs)
if evs[0].USD != first {
t.Fatal("Dormancy must not reorder the caller's slice")
}
}
func TestRoundFraction(t *testing.T) {
evs := []history.Event{
{USD: 10000}, {USD: 25000}, {USD: 5000}, {USD: 4237.19},
}
if got := Round(evs, 1000); got != 0.75 {
t.Fatalf("round fraction = %v, want 0.75", got)
}
if got := Round(evs, 0); got != 0 {
t.Fatalf("a non-positive unit must return 0, got %v", got)
}
if got := Round(nil, 1000); got != 0 {
t.Fatalf("an empty window must return 0, got %v", got)
}
}
func TestDeviationIsUnmovedByOneOutlier(t *testing.T) {
// A customer whose ordinary transaction is about 100, with one past
// 1,000,000. A mean-and-standard-deviation baseline is destroyed by that
// outlier and scores the next 50,000 as unremarkable. The median absolute
// deviation is not.
evs := []history.Event{
{USD: 100}, {USD: 105}, {USD: 95}, {USD: 102}, {USD: 98},
{USD: 1_000_000},
}
z := Deviation(evs, 50000, 4)
if z < 3.5 {
t.Fatalf("robust deviation of 50,000 = %v, want >= 3.5 despite the outlier in the baseline", z)
}
// Show the classical statistic fails on the same data, which is why it is
// not the one used.
if classical := zMeanStdDev(evs, 50000); math.Abs(classical) >= 3.5 {
t.Fatalf("mean/stddev z = %v; the test premise requires it to miss (< 3.5)", classical)
}
}
func TestDeviationHasNoOpinionOnSmallWindows(t *testing.T) {
// The baseline has genuine spread, so the score would be enormous if it were
// computed at all. Three events is still too thin a baseline to accuse anyone
// on, and the measure must decline rather than produce a confident number
// from almost no data.
evs := []history.Event{{USD: 100}, {USD: 105}, {USD: 95}}
if z := Deviation(evs, 999999, 4); z != 0 {
t.Fatalf("deviation over 3 events with min 4 = %v, want 0 — too little baseline to judge", z)
}
// Adding a fourth event of the same shape reaches the minimum and the measure
// then does have an opinion, which proves the gate is the window size and not
// something else about the data.
evs = append(evs, history.Event{USD: 102})
if z := Deviation(evs, 999999, 4); z == 0 {
t.Fatal("at the minimum window the measure must produce a score")
}
}
func TestDeviationHasNoOpinionWithoutSpread(t *testing.T) {
// Every past amount identical: the median absolute deviation is zero and the
// score would divide by zero. No opinion is the honest answer.
evs := []history.Event{{USD: 100}, {USD: 100}, {USD: 100}, {USD: 100}, {USD: 100}}
if z := Deviation(evs, 100000, 4); z != 0 {
t.Fatalf("deviation with zero spread = %v, want 0", z)
}
}
// zMeanStdDev is the classical z-score, present only so the test above can
// demonstrate the failure it avoids.
func zMeanStdDev(evs []history.Event, value float64) float64 {
if len(evs) < 2 {
return 0
}
var mean float64
for _, e := range evs {
mean += e.USD
}
mean /= float64(len(evs))
var ss float64
for _, e := range evs {
ss += (e.USD - mean) * (e.USD - mean)
}
sd := math.Sqrt(ss / float64(len(evs)-1))
if sd == 0 {
return 0
}
return (value - mean) / sd
}
func TestDistinctCountsParties(t *testing.T) {
evs := []history.Event{
{Counterparty: "a"}, {Counterparty: "b"}, {Counterparty: "a"}, {Counterparty: ""},
}
if got := Distinct(evs, func(e history.Event) string { return e.Counterparty }); got != 2 {
t.Fatalf("distinct counterparties = %d, want 2 (empty values are not a party)", got)
}
}
func TestSumAndCountAndMax(t *testing.T) {
evs := []history.Event{{USD: 10}, {USD: 30}, {USD: 20}}
if got := Sum(evs); got != 60 {
t.Fatalf("sum = %v, want 60", got)
}
if got := Count(evs); got != 3 {
t.Fatalf("count = %d, want 3", got)
}
if got := Max(evs); got != 30 {
t.Fatalf("max = %v, want 30", got)
}
}
+69
View File
@@ -0,0 +1,69 @@
// Package standard models a citation to a published regulatory standard.
//
// Every rule in the detection library carries at least one citation. The
// citation is the reason the rule exists: it names the authority, the document,
// and the locator within that document that requires or describes the control.
// An examiner tests the institution against the standard, so a control that
// cannot name its standard cannot be defended.
package standard
import "fmt"
// Authority is a standard-setting or supervisory body.
type Authority string
const (
// FATF is the Financial Action Task Force, which issues the 40
// Recommendations, the assessment Methodology, and typology reports.
FATF Authority = "FATF"
// FinCEN is the US Financial Crimes Enforcement Network, which
// administers the Bank Secrecy Act regulations at 31 CFR Chapter X
// and issues advisories carrying red-flag indicators.
FinCEN Authority = "FinCEN"
// OFAC is the US Office of Foreign Assets Control, which publishes the
// SDN and consolidated sanctions lists and their data specifications.
OFAC Authority = "OFAC"
// UN is the United Nations Security Council, whose committees maintain
// the consolidated sanctions list.
UN Authority = "UN"
// EU is the European Union: the anti-money-laundering directives and
// regulations, and the consolidated CFSP financial sanctions list.
EU Authority = "EU"
// UK covers UK statute and statutory instruments, and the HM Treasury
// and OFSI financial sanctions regime.
UK Authority = "UK"
// Wolfsberg is the Wolfsberg Group, an association of international
// banks publishing industry standards for financial crime compliance.
Wolfsberg Authority = "Wolfsberg"
// FFIEC is the US Federal Financial Institutions Examination Council,
// whose BSA/AML examination manual states what examiners test.
FFIEC Authority = "FFIEC"
// BIS is the US Bureau of Industry and Security, which issues joint
// export-control evasion alerts with FinCEN.
BIS Authority = "BIS"
)
// Citation locates a requirement or indicator within a published document.
//
// Locator is the smallest addressable unit the document offers: a
// Recommendation number, an interpretive-note paragraph, a CFR section, an
// article, a regulation number, or a named indicator group. It is what a
// reviewer types into the document to land on the text.
type Citation struct {
Authority Authority `json:"authority"`
Document string `json:"document"`
Locator string `json:"locator"`
URL string `json:"url"`
}
// String renders the citation for a narrative or an audit export.
func (c Citation) String() string {
return fmt.Sprintf("%s, %s, %s", c.Authority, c.Document, c.Locator)
}
// Valid reports whether the citation is complete enough to defend a control.
// A citation missing any field cannot be checked by a reviewer, so the
// library rejects it rather than shipping a claim nobody can verify.
func (c Citation) Valid() bool {
return c.Authority != "" && c.Document != "" && c.Locator != "" && c.URL != ""
}