mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
Lists, suppressions, activations, the field catalog and the model plane. Each is its own package, each is Base-backed, and none of them can destroy anything — disposal is pkg/retention's decision alone, per tenant, and every one of them has a test that reads its own source for a call to Delete. lists holds the institution's own allow and deny lists, by class, normalised once so an address written two ways is one address. It reaches a rule as one term, Listed(name, value), and it refuses both a list nobody declared and an empty value: either would be a rule that is catalogued, reported as coverage, and incapable of firing. Removing a value stops it matching and keeps the row, because a list entry is the reason a payment was refused. suppress holds the decisions to keep a detection out of a queue. Reason and decider are required and neither is defaulted; a suppression naming neither a rule nor a subject is refused, because that is a kill switch. Lifting keeps the row. The narrowest cover wins, so a specific decision about one account is the one named on the activation rather than the rule-wide one behind it. watch writes every activation before anything happens to it, and marks the ones a suppression covered or a fold matched rather than dropping them: a system that discards what it decided not to show cannot say how much it is not showing. Action is what the rule asked for, Response is what the plane concluded, and exactly two declared mechanisms move it. A rung may only raise — lowering is a suppression by another name — and elevation beats folding, because an activation that reached an escalation rung is the escalation. The live feed is a convenience over the durable rows; it drops rather than blocking ingest, and the drops are counted per tenant and published on every poll. dictionary reports what a tenant's payloads carry: declared fields discovered by reflection over the transaction rather than from a second list, the tenant's own Raw keys, fill, blindness, and a distinct count kept as a bitmap sketch — hashed with the tenant, so two tenants' bitmaps cannot be compared, and saturating into "no longer a count" rather than drifting down. No value is ever stored. It is write-behind and says so: Pending is exactly what a restart would lose. topology searches the space of model shapes over a tenant's own history, through the detector's own learning path rather than a copy of it, and reports the learning curve, the realised alert share against the stated appetite, where each candidate went blind, and which features carried the signal. It has no store and imports nothing that has one. It names NO winner where nothing has been judged: ranking needs an outcome, and a ranking invented from unlabelled data would look like a recommendation and be a preference. Ties go to the smaller model. models keeps every search and every fitted state, because both are governed acts, and adopts a fit into the live model with its decider recorded. The learned state never leaves the store — mass counters describe where a tenant's activity is dense. A fit installs only into a model of the same shape, which is what stops a tenant's model being swapped underneath it by restoring a file. Around them: anomaly.Learn exposes the assessment Assess was already computing and throwing away, so a study of the model reads the same scores production does; engine gains one provider and one vocabulary term; and the tenant key moves to pkg/brand, where the brand registry is, so the five new planes hold the same shape at their own write boundary instead of each growing an opinion about what a tenant is. api/tenant.go's qualify and qualified are now aliases of it, not a second copy. Every operation is func(ctx, org, *In) (*Out, error): the tenant is an argument and never something the operation resolves for itself. api/typed.go is the whole of what turns one into a route — query into the input for a GET, body for a POST, the path bound last and winning — and api/planes.go is a routing table with no handler bodies in it, which a test reads as source. The contract is the Go types and nothing else, so a zip face over the same functions restates none of it. Co-authored-by: Hanzo Dev <[email protected]>
170 lines
5.2 KiB
Go
170 lines
5.2 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/luxfi/aml/pkg/history"
|
|
)
|
|
|
|
// Screening classes. Sanctions and PEP screening share one mechanism — match a
|
|
// name against a curated list — but they answer different questions and carry
|
|
// different consequences, so a rule names which it is asking.
|
|
const (
|
|
ClassSanctions = "sanctions"
|
|
ClassPEP = "pep"
|
|
)
|
|
|
|
// Jurisdiction risk tiers, mirroring the two lists a standard-setter publishes:
|
|
// jurisdictions calling for countermeasures, and jurisdictions under increased
|
|
// monitoring. A rule distinguishes them because the required response differs.
|
|
const (
|
|
TierNone = ""
|
|
TierMonitoring = "monitoring"
|
|
TierAction = "action"
|
|
)
|
|
|
|
// Hit is the outcome of screening a name against a list.
|
|
type Hit struct {
|
|
Matched bool `json:"matched"`
|
|
Score float64 `json:"score"`
|
|
List string `json:"list,omitempty"`
|
|
EntryID string `json:"entry_id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// Screen matches a name against a screening list.
|
|
type Screen interface {
|
|
Hit(ctx context.Context, name, class string) (Hit, error)
|
|
}
|
|
|
|
// Reference answers questions about published reference data.
|
|
//
|
|
// Jurisdiction returns the risk tier for an ISO 3166-1 alpha-2 country code. It
|
|
// returns an error when no list has been loaded: an empty list would answer
|
|
// "not listed" for every country on earth, which is the same silent no-op as
|
|
// having no control at all.
|
|
type Reference interface {
|
|
Jurisdiction(code string) (string, error)
|
|
}
|
|
|
|
// Lists answers whether a value is on one of the institution's own allow or deny
|
|
// lists.
|
|
//
|
|
// The org is the tenant KEY the transaction under evaluation carries, so a rule
|
|
// can only ever read its own institution's lists — the same value that indexes
|
|
// every other record plane, passed through rather than resolved again here.
|
|
//
|
|
// It returns an error for a list nobody declared, and for an empty value, rather
|
|
// than reporting "not listed". Both would be a rule that is present in the
|
|
// catalog, visible in the interface, counted as coverage, and incapable of
|
|
// firing — which is the failure ErrNoProvider exists to prevent, arrived at from
|
|
// the data side instead of the configuration side.
|
|
type Lists interface {
|
|
Listed(ctx context.Context, org, name, value string) (bool, error)
|
|
}
|
|
|
|
// Rate converts an amount into USD.
|
|
//
|
|
// It returns an error for a currency it does not know rather than passing the
|
|
// amount through unchanged. Treating an unknown amount as already-USD moves
|
|
// every threshold by the exchange rate: 10,000 units of a currency worth three
|
|
// dollars each is a 30,000-dollar transaction, and passing it through as 10,000
|
|
// leaves it exactly on the wrong side of a 10,000-dollar threshold.
|
|
type Rate interface {
|
|
USD(ctx context.Context, amount float64, currency string) (float64, error)
|
|
}
|
|
|
|
// Providers are the evidence sources the rule vocabulary is built from.
|
|
//
|
|
// A rule may only be admitted if every provider its expression needs is
|
|
// present. Nothing here has a default: a missing provider is a configuration
|
|
// error surfaced when rules are installed, not a value invented at evaluation
|
|
// time.
|
|
type Providers struct {
|
|
History history.Store
|
|
Screen Screen
|
|
Reference Reference
|
|
Rate Rate
|
|
Lists Lists
|
|
|
|
// Now supplies the evaluation instant. Tests set it; production leaves it nil
|
|
// and gets the wall clock.
|
|
Now func() time.Time
|
|
|
|
// Zone is the institution's business day, used for calendar-day aggregation.
|
|
// Nil means UTC.
|
|
Zone *time.Location
|
|
}
|
|
|
|
// capability names the evidence source a vocabulary term needs.
|
|
type capability string
|
|
|
|
const (
|
|
capHistory capability = "history"
|
|
capScreen capability = "screen"
|
|
capReference capability = "reference"
|
|
capRate capability = "rate"
|
|
capLists capability = "lists"
|
|
)
|
|
|
|
// vocabulary maps each term a rule expression may call to the provider that
|
|
// answers it. A term absent from this table is not part of the language, and a
|
|
// term present here cannot be used unless its provider is configured.
|
|
//
|
|
// This table is the single place the language is defined. Adding a scope method
|
|
// without adding it here makes the method unreachable from a rule, which the
|
|
// vocabulary test detects.
|
|
var vocabulary = map[string]capability{
|
|
"Count": capHistory,
|
|
"Sum": capHistory,
|
|
"Max": capHistory,
|
|
"Day": capHistory,
|
|
"Distinct": capHistory,
|
|
"Seen": capHistory,
|
|
"Structured": capHistory,
|
|
"InOut": capHistory,
|
|
"Dormant": capHistory,
|
|
"Round": capHistory,
|
|
"Deviation": capHistory,
|
|
"Screened": capScreen,
|
|
"Tier": capReference,
|
|
"USD": capRate,
|
|
"Near": capRate,
|
|
"Listed": capLists,
|
|
}
|
|
|
|
func (p Providers) has(c capability) bool {
|
|
switch c {
|
|
case capHistory:
|
|
return p.History != nil
|
|
case capScreen:
|
|
return p.Screen != nil
|
|
case capReference:
|
|
return p.Reference != nil
|
|
case capRate:
|
|
return p.Rate != nil
|
|
case capLists:
|
|
return p.Lists != nil
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ErrNoProvider reports a rule that names evidence the deployment cannot supply.
|
|
var ErrNoProvider = errors.New("no provider")
|
|
|
|
func (p Providers) now() time.Time {
|
|
if p.Now != nil {
|
|
return p.Now()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func (p Providers) zone() *time.Location {
|
|
if p.Zone != nil {
|
|
return p.Zone
|
|
}
|
|
return time.UTC
|
|
}
|