The defining failure of the last review was not in a plane. It was that there were TWO wirings: cmd/amld assembled a Handler by hand and the tests assembled a different one, so the record fingerprint could be a struct field the suite exercised and no column stored — every retry a permanent 409 in production while every test passed. api.Wire is now the only assembly. It creates every collection, opens every shelf, makes every join and registers what has to run on a cadence; cmd/amld supplies only what an installation answers differently, the tests call the same function, and internal/source.NoLiteral reads cmd/amld to refuse a second copy. Handler completeness is checked by reflection, because a hand-written field list would be the third copy. A retry is not a second transaction. pkg/receipt resolves the identity of an OFFER once, at the top of ingest, before anything is read or written, and returns the prior answer byte for byte — so the ledger, the aggregates, the history, the alerts and the case plane all agree on one transaction instead of the ledger holding one record while the aggregates hold three. Two different facts under one reference is 409 and never 503. Because the answer is kept AFTER the work, a process that dies in between leaves no receipt, so every plane past it also recognises the transaction for itself: history does not append a second row, alerts keep the first judgement, and a judged transaction opens no second case. A bound over a COUNT of caller-sized values is not a bound. types.MaxIdent bounds every identifier at the door, which is what turns the counts downstream into figures in bytes; dictionary.MaxName does the same for a payload key, and dictionary.Ceiling is the product a test WEIGHS a full accumulator against. The field catalog's accumulator moves into roster, which admits and never removes, so per-tenant bounds multiply by ONE number an operator can hold against a pod. engine.Evaluator now keeps no compiled rules at all. A candidate's text arrives on the wire at /v1/aml/rules/test, so a compiled-rule table there was keyed by a string a caller wrote, sized by how many distinct ones a caller sends, with no cap and no removal, in the memory every institution's ingest runs in — an OOM from one authenticated caller, which on this deployment is every control off at once. A rule's compiled form is a value with an owner: the installed library, or one replay. Nothing shared, nothing to grow. Found here, not reported. Handler.history is the one function that materialises a whole retained history, so it now REQUIRES a *topology.Grant, and a Grant comes from Budget.Admit and nowhere else. An ungated whole-history read is something a caller has to write on purpose rather than something a new door can forget. The rule replay and the activation rate fold take the same one budget the model studies take. Reaching a bound is allowed; reaching it quietly is not. GET /v1/aml/load is the one door for "is anything of mine quietly degraded": the tenant's aggregates against its own ceiling in bytes with a grade, whether it has a behavioural model at all, the field catalog's pressure, and the process's counts of institutions held and refused. It names no other institution. Closing R1-R8 from the review plus R11, the two recurring classes, and the silent degradation the load door answers. Twenty-four mutations, twenty-four killed. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
215 lines
7.0 KiB
Go
215 lines
7.0 KiB
Go
// Package engine evaluates transactions against the rule library.
|
|
//
|
|
// The engine holds a rule set it has admitted: every rule in it parses, names
|
|
// only evidence this deployment can supply, and returns a boolean. Admission is
|
|
// the point. A rule set that installs regardless of whether its evidence exists
|
|
// produces a monitoring programme whose coverage claim cannot be checked by
|
|
// reading it.
|
|
package engine
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/luxfi/aml/pkg/types"
|
|
)
|
|
|
|
// Engine evaluates transactions against an admitted rule set.
|
|
type Engine struct {
|
|
eval *Evaluator
|
|
|
|
mu sync.RWMutex
|
|
// rules is the installed library, compiled. It is the ONE thing this process
|
|
// keeps compiled rules in, and what it holds is what the deployment
|
|
// installed — see Evaluator, which keeps none.
|
|
rules *Ruleset
|
|
scorer Scorer
|
|
|
|
faults atomic.Int64
|
|
rejects atomic.Int64
|
|
}
|
|
|
|
// New builds an engine over the given evidence providers with an empty rule set.
|
|
// Install rules with SetRules, which reports any the deployment cannot support.
|
|
func New(p Providers) *Engine {
|
|
return &Engine{eval: NewEvaluator(p)}
|
|
}
|
|
|
|
// SetRules installs a rule set, or installs nothing.
|
|
//
|
|
// Every rule is admitted first and the whole set is rejected if any rule fails.
|
|
// A partially installed set is a control surface nobody can describe: the
|
|
// operator believes the catalog is in force, and the difference is discoverable
|
|
// only by reading startup logs.
|
|
func (e *Engine) SetRules(rules []types.Rule) error {
|
|
set, err := e.eval.Ready(rules)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
e.mu.Lock()
|
|
e.rules = set
|
|
e.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Rules returns a copy of the installed rule set.
|
|
func (e *Engine) Rules() []types.Rule { return e.ready().Rules() }
|
|
|
|
// ready is the installed library as it stands, compiled.
|
|
func (e *Engine) ready() *Ruleset {
|
|
e.mu.RLock()
|
|
defer e.mu.RUnlock()
|
|
return e.rules
|
|
}
|
|
|
|
// Evaluator exposes the evaluator, for admitting a candidate rule before saving
|
|
// it and for reporting the vocabulary this deployment can answer.
|
|
func (e *Engine) Evaluator() *Evaluator { return e.eval }
|
|
|
|
// SetScorer installs the Scorer consulted alongside the rules, or nil to run on
|
|
// rules alone.
|
|
func (e *Engine) SetScorer(s Scorer) {
|
|
e.mu.Lock()
|
|
e.scorer = s
|
|
e.mu.Unlock()
|
|
}
|
|
|
|
// ScorerFaults counts the times the Scorer's evidence was discarded — a panic, a
|
|
// negative weight, or a weight that is not a number.
|
|
//
|
|
// It has to be readable. A Scorer that faults on every transaction contributes
|
|
// nothing while the engine keeps answering, and this counter is the only thing
|
|
// that distinguishes that from a Scorer finding nothing to report.
|
|
func (e *Engine) ScorerFaults() int64 { return e.faults.Load() + e.rejects.Load() }
|
|
|
|
// assess consults the Scorer, and confines it.
|
|
//
|
|
// Three things a Scorer must not be able to do, each enforced here rather than
|
|
// trusted to the Scorer, because the property has to hold for a broken one as
|
|
// well as an honest one:
|
|
//
|
|
// It must not take the rule plane down. Every rule has already been evaluated by
|
|
// the time this runs, and losing that verdict to a fault in the statistical plane
|
|
// would turn a degraded control into no control — so a panic is contained,
|
|
// counted, and the rules' answer stands.
|
|
//
|
|
// It must not weaken a verdict. Weight-of-evidence is a sum, so a negative weight
|
|
// would subtract from a score the rules built and a model could argue a
|
|
// transaction down. Non-negative weights are already required of rules; a
|
|
// computed weight is held to the same bar, along with the NaN a computation can
|
|
// produce and a constant cannot.
|
|
//
|
|
// It must not act. Its action is capped at ActionCeiling, so the strongest thing a
|
|
// statistical judgement can do is put a transaction in front of a person.
|
|
func (e *Engine) assess(tx types.Transaction, ent types.Entity) (hit types.RuleHit, ok bool) {
|
|
e.mu.RLock()
|
|
sc := e.scorer
|
|
e.mu.RUnlock()
|
|
if sc == nil {
|
|
return types.RuleHit{}, false
|
|
}
|
|
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
e.faults.Add(1)
|
|
hit, ok = types.RuleHit{}, false
|
|
}
|
|
}()
|
|
|
|
hit, ok = sc.Assess(tx, ent)
|
|
if !ok {
|
|
return types.RuleHit{}, false
|
|
}
|
|
if hit.Rule.Weight < 0 || math.IsNaN(hit.Rule.Weight) || math.IsInf(hit.Rule.Weight, 0) {
|
|
e.rejects.Add(1)
|
|
return types.RuleHit{}, false
|
|
}
|
|
if types.ActionRank(hit.Rule.Action) > types.ActionRank(types.ActionCeiling) {
|
|
hit.Rule.Action = types.ActionCeiling
|
|
}
|
|
hit.Match = true
|
|
return hit, true
|
|
}
|
|
|
|
// Evaluate runs the rule set against a transaction and its customer, returning
|
|
// the alerts raised, the aggregate risk score, and the action to take.
|
|
func (e *Engine) Evaluate(ctx context.Context, tx types.Transaction, ent types.Entity) ([]types.Alert, float64, string) {
|
|
hits := e.ready().EvalAll(ctx, tx, ent)
|
|
|
|
// The Scorer contributes evidence the rule library cannot express. It is
|
|
// consulted after the rules so it can only add to what they found: it can
|
|
// raise a transaction no rule matched, which is why it exists, and cannot
|
|
// lower one they did.
|
|
if hit, ok := e.assess(tx, ent); ok {
|
|
hits = append(hits, hit)
|
|
}
|
|
if len(hits) == 0 {
|
|
return nil, 0, types.ActionAllow
|
|
}
|
|
|
|
// Only rules that reached a verdict carry weight. A rule that failed to
|
|
// evaluate has established nothing about this transaction, and because a rule
|
|
// that fails once fails on every transaction, letting it score saturates every
|
|
// score and the queue stops ranking — which is how an ordinary payment comes
|
|
// back at the maximum. It still raises its own alert naming its own failure,
|
|
// and action() still sends that to a person.
|
|
evidential := make([]types.RuleHit, 0, len(hits))
|
|
for _, h := range hits {
|
|
if h.EvalErr == "" {
|
|
evidential = append(evidential, h)
|
|
}
|
|
}
|
|
|
|
score, breakdown := Score(evidential)
|
|
|
|
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,
|
|
Typology: h.Rule.Typology,
|
|
Citations: h.Rule.Citations,
|
|
Severity: h.Rule.Severity,
|
|
Score: breakdown[h.Rule.ID],
|
|
ScoreBreakdown: breakdown,
|
|
ActionTaken: action(h),
|
|
Causes: h.Causes,
|
|
EvalErr: h.EvalErr,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
return alerts, score, resolve(alerts)
|
|
}
|
|
|
|
// action is the response a hit calls for. A hit carrying an evaluation failure
|
|
// goes to review whatever the rule would otherwise have done: the rule reached
|
|
// no verdict, so neither blocking nor clearing is supportable and a person has
|
|
// to look.
|
|
func action(h types.RuleHit) string {
|
|
if h.EvalErr != "" {
|
|
return types.ActionReview
|
|
}
|
|
return h.Rule.Action
|
|
}
|
|
|
|
// resolve picks the most restrictive action across a set of alerts.
|
|
func resolve(alerts []types.Alert) string {
|
|
best, bestRank := types.ActionAllow, 0
|
|
for _, a := range alerts {
|
|
if r := types.ActionRank(a.ActionTaken); r > bestRank {
|
|
best, bestRank = a.ActionTaken, r
|
|
}
|
|
}
|
|
return best
|
|
}
|