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:
2026-07-29 11:01:22 -07:00
co-authored by Hanzo Dev
parent 039fa7e3d6
commit 04accf64e0
6 changed files with 142 additions and 34 deletions
+110
View File
@@ -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)
}
}
-4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+3 -6
View File
@@ -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)