mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
engine, history: one definition of a subject that names nobody
Subject.Valid is the single place that decides what an unusable subject is — unknown kind, no tenant, no identifier — and the scope that builds subjects and both stores that consume them all defer to it. Each of those failures has the same consequence and it is the worst one available: a subject naming nobody yields an empty window, and a rule over an empty window sees no activity, which is indistinguishable from a customer who did nothing. A rule that reaches no verdict now records review regardless of the action it was configured with. A failing flag-only rule escalates, because nobody looks at a flag; a failing block rule does not block, because an evidence outage would otherwise decline every payment. Reference data refuses rather than guesses: an unloaded or undated jurisdiction listing fails loudly instead of answering not-listed for every country on earth. Dropped the empty-expression check, which the parser already makes. 22 of 22 mutations to the engine, history and reference guards are killed by the suite; 58 of 58 across measure, sanctions and engine together. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -467,3 +467,113 @@ func (c *counting) Window(ctx context.Context, s history.Subject, d time.Duratio
|
||||
c.calls++
|
||||
return c.Store.Window(ctx, s, d)
|
||||
}
|
||||
|
||||
func TestFailingRuleIsEscalatedNotDowngraded(t *testing.T) {
|
||||
// A rule that reached no verdict must go to review whatever it would otherwise
|
||||
// have done. A failing flag-only rule must escalate, because nobody looks at a
|
||||
// flag; and a failing block rule must not block, because an evidence outage
|
||||
// would then decline every payment.
|
||||
for _, configured := range []string{types.ActionFlag, types.ActionBlock, types.ActionReport, types.ActionAllow} {
|
||||
e := New(Providers{
|
||||
History: failing{},
|
||||
Rate: reference.Rates{AsOf: ref},
|
||||
Now: func() time.Time { return ref },
|
||||
})
|
||||
r := rule("velocity", `Sum("user", "24h") > 1000.0`)
|
||||
r.Action = configured
|
||||
if err := e.SetRules([]types.Rule{r}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alerts, _, action := e.Evaluate(context.Background(), tx(500), types.Entity{ID: "u1"})
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("%s: want one alert, got %d", configured, len(alerts))
|
||||
}
|
||||
if alerts[0].ActionTaken != types.ActionReview {
|
||||
t.Errorf("a rule configured to %s that failed must record review, got %q", configured, alerts[0].ActionTaken)
|
||||
}
|
||||
if action != types.ActionReview {
|
||||
t.Errorf("a rule configured to %s that failed must resolve to review, got %q", configured, action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownScreeningClassIsRejected(t *testing.T) {
|
||||
e := New(providers(history.NewMemory(func() time.Time { return ref })))
|
||||
if err := e.SetRules([]types.Rule{rule("r", `Screened(Entity.Name, "watchlist")`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alerts, _, action := e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1", Name: "Ivan Petrov"})
|
||||
if len(alerts) != 1 || alerts[0].EvalErr == "" || action != types.ActionReview {
|
||||
t.Fatalf("an unrecognised screening class must be recorded as a failure, got %d alerts action %q", len(alerts), action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedWindowUnitsRejected(t *testing.T) {
|
||||
for _, w := range []string{"xh", "7", "24x", "-3d", "0d", "", "h"} {
|
||||
e := New(providers(history.NewMemory(func() time.Time { return ref })))
|
||||
if err := e.SetRules([]types.Rule{rule("r", `Count("user", "`+w+`") > 0`)}); err != nil {
|
||||
t.Fatalf("%q: %v", w, err)
|
||||
}
|
||||
alerts, _, action := e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1"})
|
||||
if len(alerts) != 1 || alerts[0].EvalErr == "" || action != types.ActionReview {
|
||||
t.Errorf("window %q must be rejected at evaluation, got %d alerts action %q", w, len(alerts), action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnloadedJurisdictionListingFailsLoudly(t *testing.T) {
|
||||
// The whole point of the reference provider. An empty listing answering
|
||||
// "not listed" for every country reads as a world with no high-risk
|
||||
// jurisdictions in it, and no operator would ever notice.
|
||||
e := New(Providers{
|
||||
History: history.NewMemory(func() time.Time { return ref }),
|
||||
Reference: reference.Jurisdictions{},
|
||||
Rate: reference.Rates{AsOf: ref},
|
||||
Now: func() time.Time { return ref },
|
||||
})
|
||||
if err := e.SetRules([]types.Rule{rule("r", `Tier(Entity.Jurisdiction) == "action"`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alerts, _, action := e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1", Jurisdiction: "KP"})
|
||||
if len(alerts) != 1 || alerts[0].EvalErr == "" || action != types.ActionReview {
|
||||
t.Fatalf("an unloaded jurisdiction listing must fail loudly, got %d alerts action %q", len(alerts), action)
|
||||
}
|
||||
|
||||
// A listing with entries but no date is equally unusable: its currency cannot
|
||||
// be assessed, and a listing from two years ago is not the current one.
|
||||
e2 := New(Providers{
|
||||
History: history.NewMemory(func() time.Time { return ref }),
|
||||
Reference: reference.Jurisdictions{Action: []string{"KP"}},
|
||||
Rate: reference.Rates{AsOf: ref},
|
||||
Now: func() time.Time { return ref },
|
||||
})
|
||||
if err := e2.SetRules([]types.Rule{rule("r", `Tier(Entity.Jurisdiction) == "action"`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alerts, _, _ = e2.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1", Jurisdiction: "KP"})
|
||||
if len(alerts) != 1 || alerts[0].EvalErr == "" {
|
||||
t.Fatal("an undated jurisdiction listing must fail loudly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubjectValidationIsSharedByStoreAndScope(t *testing.T) {
|
||||
// The scope builds Subjects and the stores consume them; both defer to the same
|
||||
// validation, so there is one definition of what naming nobody means.
|
||||
store := history.NewMemory(func() time.Time { return ref })
|
||||
for _, s := range []history.Subject{
|
||||
{OrgID: "org", Kind: "customer", ID: "u1"},
|
||||
{OrgID: "", Kind: history.SubjectUser, ID: "u1"},
|
||||
{OrgID: "org", Kind: history.SubjectUser, ID: ""},
|
||||
} {
|
||||
if _, err := store.Window(context.Background(), s, time.Hour); err == nil {
|
||||
t.Errorf("subject %+v must be rejected by the store", s)
|
||||
}
|
||||
if err := s.Valid(); err == nil {
|
||||
t.Errorf("subject %+v must be reported invalid", s)
|
||||
}
|
||||
}
|
||||
ok := history.Subject{OrgID: "org", Kind: history.SubjectUser, ID: "u1"}
|
||||
if err := ok.Valid(); err != nil {
|
||||
t.Errorf("a complete subject must be valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +73,6 @@ func (e *Evaluator) Admit(r types.Rule) (*vm.Program, error) {
|
||||
return prog, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(r.DSL) == "" {
|
||||
return nil, fmt.Errorf("rule %q: expression is empty", r.ID)
|
||||
}
|
||||
|
||||
tree, err := parser.Parse(r.DSL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rule %q: %w", r.ID, err)
|
||||
|
||||
+4
-7
@@ -61,15 +61,12 @@ func (s *scope) subject(kind string) (history.Subject, error) {
|
||||
id = s.Tx.DeviceFingerprint
|
||||
case history.SubjectAddress:
|
||||
id = s.Tx.IPAddress
|
||||
default:
|
||||
return history.Subject{}, fmt.Errorf("unknown subject %q: want one of %s, %s, %s, %s, %s",
|
||||
kind, history.SubjectUser, history.SubjectAccount, history.SubjectCounterparty,
|
||||
history.SubjectDevice, history.SubjectAddress)
|
||||
}
|
||||
if id == "" {
|
||||
return history.Subject{}, fmt.Errorf("subject %q is empty on transaction %s", kind, s.Tx.ID)
|
||||
subj := history.Subject{OrgID: s.Tx.OrgID, Kind: kind, ID: id}
|
||||
if err := subj.Valid(); err != nil {
|
||||
return history.Subject{}, fmt.Errorf("%w (transaction %s)", err, s.Tx.ID)
|
||||
}
|
||||
return history.Subject{OrgID: s.Tx.OrgID, Kind: kind, ID: id}, nil
|
||||
return subj, nil
|
||||
}
|
||||
|
||||
// window fetches and memoises the subject's events over the lookback.
|
||||
|
||||
+3
-9
@@ -60,16 +60,10 @@ func NewBase(app core.App) *Base { return &Base{app: app} }
|
||||
// aggregating a reporting period by insertion time attributes activity to the day
|
||||
// the importer ran.
|
||||
func (b *Base) Window(ctx context.Context, subj Subject, lookback time.Duration) ([]Event, error) {
|
||||
field, ok := subjectField[subj.Kind]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("history: unknown subject kind %q", subj.Kind)
|
||||
}
|
||||
if subj.OrgID == "" {
|
||||
return nil, fmt.Errorf("history: subject has no organisation, which would query across tenants")
|
||||
}
|
||||
if subj.ID == "" {
|
||||
return nil, fmt.Errorf("history: subject %q has no identifier", subj.Kind)
|
||||
if err := subj.Valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
field := subjectField[subj.Kind]
|
||||
|
||||
since := time.Now().UTC().Add(-lookback)
|
||||
filter := fmt.Sprintf("%s = {:org} && %s = {:id} && %s >= {:since}", fieldOrg, field, fieldAt)
|
||||
|
||||
+22
-8
@@ -11,6 +11,8 @@ package history
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -76,13 +78,25 @@ type Store interface {
|
||||
Window(ctx context.Context, subj Subject, lookback time.Duration) ([]Event, error)
|
||||
}
|
||||
|
||||
// unknownKind and incomplete are the two ways a subject can fail to name anyone.
|
||||
// Both are errors rather than empty windows: a rule evaluated over an empty window
|
||||
// reports no activity, which is indistinguishable from a customer who did nothing.
|
||||
func unknownKind(kind string) error {
|
||||
return fmt.Errorf("history: unknown subject kind %q", kind)
|
||||
}
|
||||
// Kinds are the subject kinds a window can be grouped by.
|
||||
var Kinds = []string{SubjectUser, SubjectAccount, SubjectCounterparty, SubjectDevice, SubjectAddress}
|
||||
|
||||
func incomplete(s Subject) error {
|
||||
return fmt.Errorf("history: subject %q is incomplete (organisation %q, identifier %q)", s.Kind, s.OrgID, s.ID)
|
||||
// Valid reports why a subject cannot name anyone, if it cannot.
|
||||
//
|
||||
// This is defined once and called by everything that builds or consumes a
|
||||
// Subject, because each of these failures has the same consequence and it is the
|
||||
// worst one available: a subject that names nobody yields an empty window, a rule
|
||||
// over an empty window sees no activity, and no activity is indistinguishable
|
||||
// from a customer who did nothing. Every one of them is an error instead.
|
||||
func (s Subject) Valid() error {
|
||||
if !slices.Contains(Kinds, s.Kind) {
|
||||
return fmt.Errorf("history: unknown subject kind %q, want one of %s", s.Kind, strings.Join(Kinds, ", "))
|
||||
}
|
||||
if s.OrgID == "" {
|
||||
return fmt.Errorf("history: subject %q names no organisation, so the window would cross tenants", s.Kind)
|
||||
}
|
||||
if s.ID == "" {
|
||||
return fmt.Errorf("history: subject %q carries no identifier", s.Kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,13 +37,10 @@ func (m *Memory) Append(orgID string, e Event) {
|
||||
|
||||
// Window returns the subject's events over the lookback, most recent first.
|
||||
func (m *Memory) Window(_ context.Context, subj Subject, lookback time.Duration) ([]Event, error) {
|
||||
field, ok := identify(subj.Kind)
|
||||
if !ok {
|
||||
return nil, unknownKind(subj.Kind)
|
||||
}
|
||||
if subj.OrgID == "" || subj.ID == "" {
|
||||
return nil, incomplete(subj)
|
||||
if err := subj.Valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
field, _ := identify(subj.Kind)
|
||||
|
||||
since := m.reference().Add(-lookback)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user