mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
Two branches had independently rebuilt the same two packages, which is one
implementation too many. This resolves them into one.
Their side is the base for detection and screening, because it is better:
- Rules name evidence, and SetRules refuses a set whose evidence this
deployment cannot supply — so the eleven stub helpers that silently disabled
thirteen rules cannot exist. A rule that cannot fire is worse than an absent
one, because it still appears in the catalog a regulator reads.
- One screening store, filled by the refresh and read by the endpoints. Two of
them is why sanctions search answered "no match" for every name while
designations were being loaded somewhere else.
- Parsers for all four publishers against their real files, recovering what the
single-parser path could not read at all: OFAC's digital-currency addresses,
weak aliases held apart from screening candidates, and OFSI's row-per-name
layout collapsed to subjects.
- Name matching over runes, not bytes. Two distinct Han names scored 0.867
byte-wise, above the 0.85 threshold, and 0.700 correctly.
- Deviation from the median and MAD rather than the mean, because a monitoring
baseline contains the outliers it is used to hunt.
- A rule vocabulary with providers behind every term, in place of expressions
reaching into a struct.
Kept from this side, because their branch did not have it:
- The tenant comes from an authenticated identity, not from a request header.
- A rule that could not run carries no weight. Their action() already capped a
failing rule at review, which is right, but the hit still entered the score
at full weight — so a broken rule saturated every transaction's score and the
queue stopped ranking. Faults are now excluded from the aggregate while still
raising their own alert and still forcing review.
- Per-source readiness: which list, how many designations, how long ago, and
the reason if the last attempt failed. Their store answers whether screening
can be relied on at all; this answers what is behind it, which is what an
operator can act on.
- Behavioural scoring, retention, tokenisation, replay and reference data.
Conversion now refuses an unknown currency instead of passing the amount
through. Passing it through understates every currency worth more than a dollar,
so a payment three times over a reporting limit tested as sitting under it.
One ordering table for actions (types.ActionRank), one EvalAll signature, one
screening store, one readiness surface. 15 packages, tests and race clean.
Co-authored-by: Hanzo Dev <[email protected]>
104 lines
3.6 KiB
Go
104 lines
3.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
|
|
"github.com/luxfi/aml/pkg/anomaly"
|
|
"github.com/luxfi/aml/pkg/types"
|
|
)
|
|
|
|
// anomalyState reports the model a governance review has to be able to read: the
|
|
// appetite it was given, the mapping from typologies to the features it reads, the
|
|
// threshold that follows from the appetite, the rate it actually achieved against
|
|
// the rate it was asked for, what it declined to score and why, and the sample
|
|
// retained below the line so that what it missed can be measured.
|
|
//
|
|
// A bought-in system does not transfer responsibility: the firm has to understand
|
|
// how the scoring works and how it weights what it reads, and demonstrate that the
|
|
// scores reflect its own understanding of risk. This endpoint is where that is
|
|
// demonstrated from, so everything needed is here and nothing needed is only in a
|
|
// document.
|
|
//
|
|
// Scoped to the caller's tenant. One tenant's alert rate and volumes are not
|
|
// another's to read.
|
|
func (h *Handler) anomalyState() func(e *core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
orgID, err := h.tenant(e)
|
|
if err != nil {
|
|
return refuse(e, err)
|
|
}
|
|
if h.Anomaly == nil {
|
|
return e.JSON(http.StatusOK, map[string]any{
|
|
"enabled": false,
|
|
"reason": "no model configured; transactions are evaluated on rules alone",
|
|
})
|
|
}
|
|
return e.JSON(http.StatusOK, struct {
|
|
Enabled bool `json:"enabled"`
|
|
anomaly.State
|
|
// Faults is how often the engine discarded the model's evidence. A
|
|
// model that faults on everything contributes nothing while the
|
|
// engine keeps answering, so the number belongs next to the rest.
|
|
Faults int64 `json:"faults"`
|
|
}{
|
|
Enabled: true,
|
|
State: h.Anomaly.State(orgID),
|
|
Faults: h.Engine.ScorerFaults(),
|
|
})
|
|
}
|
|
}
|
|
|
|
// anomalyTest tries a candidate transaction against the tenant's model without
|
|
// learning from it, moving a counter, or touching the aggregates. It is the
|
|
// model's analogue of testing a rule expression: detection has to be testable
|
|
// before it is activated, and a score nobody can reproduce on a case they choose
|
|
// is not one anyone can be asked to rely on.
|
|
//
|
|
// The answer carries the full attribution and every coordinate the model read,
|
|
// including the ones that contributed nothing — what it looked at is as much part
|
|
// of the account as what it concluded.
|
|
func (h *Handler) anomalyTest() func(e *core.RequestEvent) error {
|
|
return func(e *core.RequestEvent) error {
|
|
orgID, err := h.tenant(e)
|
|
if err != nil {
|
|
return refuse(e, err)
|
|
}
|
|
if h.Anomaly == nil {
|
|
return fail(e, http.StatusNotFound, "no model configured")
|
|
}
|
|
|
|
var in struct {
|
|
Tx types.Transaction `json:"tx"`
|
|
Entity types.Entity `json:"entity"`
|
|
}
|
|
if err := json.NewDecoder(e.Request.Body).Decode(&in); err != nil {
|
|
return fail(e, http.StatusBadRequest, "invalid request body")
|
|
}
|
|
|
|
// The tenant comes from the credential, never from the body, or a caller
|
|
// could score against another tenant's model and read its behaviour back
|
|
// out of the answer one probe at a time.
|
|
tx := in.Tx
|
|
tx.OrgID = orgID
|
|
if h.Rate == nil {
|
|
return fail(e, http.StatusServiceUnavailable, "no conversion is configured, so no threshold can be applied")
|
|
}
|
|
usd, err := h.Rate.USD(e.Request.Context(), tx.Notional, tx.Currency)
|
|
if err != nil {
|
|
return fail(e, http.StatusBadRequest, "currency cannot be converted, so no threshold can be applied to it")
|
|
}
|
|
tx.USD = usd
|
|
if tx.Timestamp.IsZero() {
|
|
tx.Timestamp = time.Now().UTC()
|
|
}
|
|
entity := in.Entity
|
|
entity.OrgID = orgID
|
|
|
|
return e.JSON(http.StatusOK, h.Anomaly.Inspect(tx, entity))
|
|
}
|
|
}
|