Files
aml/pkg/engine/engine.go
T
zeekayandHanzo Dev 07e69e3ee2 luxfi/aml — real-time AML/CFT transaction monitoring
A Go AML engine: a configurable rules DSL over expr-lang with weight-of-evidence
scoring, sanctions screening against OFAC/UN/EU/HMT, case management, and scored
alerting. Single binary, embedded SQLite per org, embedded UI.

Surface (all /v1, org-scoped):
  /v1/aml/transactions        ingest and score
  /v1/aml/rules  /rules/test  the rules DSL and its dry-run
  /v1/aml/sanctions/search    list screening
  /v1/aml/cases               investigation workflow
  /v1/aml/health

Build: make build produces one static binary with the UI embedded (embedui tag);
bare Go tooling needs no Node. Third-party license and notice texts are generated
from the module graph the linker actually walks and ship beside every artifact.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 22:51:25 -07:00

120 lines
2.9 KiB
Go

// Package engine implements the real-time AML rule evaluation engine.
// It evaluates transactions against rules using expr-lang, scores hits
// using weight-of-evidence, and determines the enforcement action.
package engine
import (
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/luxfi/aml/pkg/types"
)
// Engine is the core AML evaluation engine.
type Engine struct {
evaluator *Evaluator
mu sync.RWMutex
rules []types.Rule
}
// New creates an Engine with the given rules.
func New(rules []types.Rule) *Engine {
return &Engine{
evaluator: NewEvaluator(),
rules: rules,
}
}
// SetRules replaces the rule set atomically. Returns an error if any rule
// has a negative weight (RED-19: prevents score suppression attacks).
func (e *Engine) SetRules(rules []types.Rule) error {
for _, r := range rules {
if r.Weight < 0 {
return fmt.Errorf("rule %q has negative weight %f: negative weights are not allowed", r.ID, r.Weight)
}
}
e.mu.Lock()
e.rules = rules
e.mu.Unlock()
return nil
}
// Rules returns a copy of the current rule set.
func (e *Engine) Rules() []types.Rule {
e.mu.RLock()
defer e.mu.RUnlock()
out := make([]types.Rule, len(e.rules))
copy(out, e.rules)
return out
}
// Evaluator returns the underlying evaluator for function registration.
func (e *Engine) Evaluator() *Evaluator {
return e.evaluator
}
// Evaluate runs all rules against a transaction and its primary entity.
// Returns the computed alerts, the aggregate score, and the enforcement action.
func (e *Engine) Evaluate(tx types.Transaction, entity types.Entity) ([]types.Alert, float64, string) {
e.mu.RLock()
rules := e.rules
e.mu.RUnlock()
ctx := types.EvalContext{
Tx: tx,
Entity: entity,
}
hits := e.evaluator.EvalAll(rules, ctx)
if len(hits) == 0 {
return nil, 0, types.ActionAllow
}
score, breakdown := Score(hits)
now := time.Now().UTC()
alerts := make([]types.Alert, 0, len(hits))
for _, h := range hits {
alerts = append(alerts, types.Alert{
ID: uuid.NewString(),
OrgID: tx.OrgID,
TxID: tx.ID,
RuleID: h.Rule.ID,
RuleName: h.Rule.Name,
Severity: h.Rule.Severity,
Score: breakdown[h.Rule.ID],
ScoreBreakdown: breakdown,
ActionTaken: h.Rule.Action,
CreatedAt: now,
UpdatedAt: now,
})
}
action := resolveAction(alerts)
return alerts, score, action
}
// resolveAction picks the most severe action from a set of alerts.
// Priority: block > report > review > flag > allow.
func resolveAction(alerts []types.Alert) string {
priority := map[string]int{
types.ActionBlock: 4,
types.ActionReport: 3,
types.ActionReview: 2,
types.ActionFlag: 1,
types.ActionAllow: 0,
}
best := types.ActionAllow
bestP := 0
for _, a := range alerts {
if p, ok := priority[a.ActionTaken]; ok && p > bestP {
best = a.ActionTaken
bestP = p
}
}
return best
}