Files
zeekayandHanzo Dev 1b7a28d31f planes: one assembly, one identity for an offer, and a bound nobody can spend twice
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 <[email protected]>
2026-08-02 11:32:15 -07:00

452 lines
18 KiB
Go

// Package types defines the canonical domain types for the AML engine.
// These types are the single source of truth for all AML operations.
//
// Every OrgID here holds the tenant KEY, not an org name: `<brand>/<org>`, the
// brand whose issuer vouched for the caller qualifying the customer organisation
// it acts for. It is minted in one place (pkg/api, tenant.go qualify) and is the
// same value that indexes the store, scopes a history row and salts the
// tokenisation vault. Writing a bare org into one of these fields puts two brands'
// institutions of the same name into one tenant — one set of records, and one
// vault, which decrypts across the two.
//
// The one exception is Rule.OrgID. The rule library is the deployment's catalog,
// seeded at startup from AML_DEFAULT_ORG before any request — so there is no Host,
// hence no brand, to qualify it with. It is the catalog's configured owner, never
// a request tenant key, and nothing scopes a store read on it. It is documented on
// the field so the distinction is stated where an auditor reads it, not inferred.
package types
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/luxfi/aml/pkg/standard"
)
// Severity levels for rules and alerts.
const (
SeverityLow = "low"
SeverityMedium = "medium"
SeverityHigh = "high"
SeverityCritical = "critical"
)
// Action constants for rule outcomes and transaction responses.
const (
ActionAllow = "allow"
ActionFlag = "flag"
ActionReview = "review"
ActionBlock = "block"
// ActionCeiling is the strongest action a statistical judgement may take. A
// model can put a transaction in front of a person; it cannot decline one on
// its own, because an unexplainable refusal is not a decision anybody can
// defend to the customer or the supervisor.
ActionCeiling = ActionReview
ActionReport = "report"
)
// Case statuses.
const (
CaseOpen = "open"
CaseInReview = "in_review"
CaseEscalated = "escalated"
CaseClosed = "closed"
)
// Case resolutions.
const (
ResolutionCleared = "cleared"
ResolutionSARFiled = "sar_filed"
ResolutionAccountFrozen = "account_frozen"
ResolutionFalsePositive = "false_positive"
)
// Alert decisions.
const (
DecisionApproved = "approved"
DecisionRejected = "rejected"
DecisionEscalated = "escalated"
)
// Entity types.
const (
EntityUser = "user"
EntityAccount = "account"
EntityCounterparty = "counterparty"
EntityBankAccount = "bank_account"
)
// Case event kinds.
const (
EventNote = "note"
EventStatusChange = "status_change"
EventFile = "file"
EventWebhook = "webhook_event"
)
// Sanctions list sources.
const (
ListOFACSDN = "ofac_sdn"
ListUN = "un"
ListEU = "eu"
ListHMT = "hmt"
ListInterpol = "interpol"
)
// Sanctions entry types.
const (
SanctionIndividual = "individual"
SanctionEntity = "entity"
SanctionVessel = "vessel"
SanctionAircraft = "aircraft"
)
// Webhook event types.
const (
WebhookAMLFlagged = "aml.flagged"
WebhookAMLCleared = "aml.cleared"
WebhookCaseOpened = "case.opened"
WebhookCaseClosed = "case.closed"
WebhookKYCApproved = "kyc.approved"
WebhookTradeExecuted = "trade.executed"
)
// Decider names whose decision a governed record carries.
//
// Every control an institution can turn off — a list entry, a suppression, a
// rung, an adopted model — is asked for a reason and a decider, so that a
// supervisor asking "who turned this off" has an answer. That answer is worth
// nothing if the caller writes it: free text in a request body attributes the
// decision to whoever the caller says, including nobody and including somebody
// else.
//
// So it is a DISTINCT TYPE, and it is the one input a caller does not supply.
// Fields of this type carry `json:"-"`: the wire has no name for one, the query
// binder skips it, and the transport writes the authenticated subject onto it
// after the body is decoded and after the path parameters are overlaid
// (pkg/api/typed.go, decide). A caller that puts `"by":"the head of compliance"`
// in a body has written a field that does not exist.
//
// The value is the token's SUBJECT rather than a display name, because
// attribution has to survive a rename: a username can be reassigned to another
// person and a subject cannot. A console resolves it to a name through IAM.
type Decider string
// Trim is the decider with surrounding space removed, as a plain string. The
// planes record text, and this is the one place the conversion happens.
func (d Decider) Trim() string { return strings.TrimSpace(string(d)) }
// Transaction is an incoming transaction event for AML evaluation.
type Transaction struct {
ID string `json:"id"`
OrgID string `json:"org_id"`
Source string `json:"source"`
UserID string `json:"user_id"`
AccountID string `json:"account_id,omitempty"`
Symbol string `json:"symbol,omitempty"`
AssetClass string `json:"asset_class,omitempty"`
Side string `json:"side,omitempty"`
Qty float64 `json:"qty"`
Notional float64 `json:"notional"`
Currency string `json:"currency"`
Counterparty string `json:"counterparty,omitempty"`
// Direction records whether value came in or went out. Without it the engine
// cannot see funds passing through an account, which is the layering signature.
Direction string `json:"direction,omitempty"`
// CustomerName and CustomerJurisdiction carry the customer record the engine
// screens and scopes rules by. The previous ingestion path synthesised a
// customer from the identifier alone, so every screening rule and every
// jurisdiction rule evaluated against an empty name and an empty country.
CustomerName string `json:"customer_name,omitempty"`
CustomerJurisdiction string `json:"customer_jurisdiction,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
DeviceFingerprint string `json:"device_fingerprint,omitempty"`
// Timestamp is when the transaction happened, and it is the ONLY clock a
// transaction carries.
//
// There was a second one — a created/updated pair the ingest path stamped
// from its own wall clock — and it made a retry impossible to recognise. A
// retained record is identified by what it SAYS, so a fact restamped on
// arrival is a different fact every time it arrives: the second submission of
// one transaction read as a conflicting second transaction under one id, and
// every further retry conflicted again. When a record was received is the
// ledger's own question and the ledger answers it (retention.Record.Written);
// a transaction states when it happened and nothing else.
Timestamp time.Time `json:"timestamp"`
Raw json.RawMessage `json:"raw,omitempty"`
// USD is Notional converted at ingest and set by the engine on the way in; a
// client-supplied value is overwritten. It is frozen at ingest rather than
// converted on read so a threshold applied over a window of past transactions
// does not move because a rate moved, and so every aggregate downstream is in
// one unit.
USD float64 `json:"usd"`
}
// Entity is a normalized actor (user, account, counterparty).
type Entity struct {
ID string `json:"id"`
OrgID string `json:"org_id"`
EntityType string `json:"entity_type"`
ExternalID string `json:"external_id,omitempty"`
Name string `json:"name"`
Jurisdiction string `json:"jurisdiction,omitempty"`
KYCLevel int `json:"kyc_level"`
PEP bool `json:"pep"`
SanctionsFlag bool `json:"sanctions_flag"`
RiskScore float64 `json:"risk_score"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Rule is one detection expressed over the evaluation vocabulary.
//
// Typology names the laundering pattern the rule detects and Citations name the
// published standards that require or describe it. Together they are what makes
// a coverage claim checkable: a reviewer reads the citation, reads the
// expression, and decides whether the second implements the first.
type Rule struct {
ID string `json:"id"`
// OrgID is the catalog's configured owner (the deployment's AML_DEFAULT_ORG),
// NOT a request tenant key — see the package doc's one exception. The library
// is seeded at startup with no Host in scope, so it cannot be brand-qualified,
// and nothing scopes a store read on it.
OrgID string `json:"org_id"`
Name string `json:"name"`
Description string `json:"description"`
Typology string `json:"typology,omitempty"`
Citations []standard.Citation `json:"citations,omitempty"`
DSL string `json:"dsl"`
Severity string `json:"severity"`
Weight float64 `json:"weight"`
Action string `json:"action"`
Enabled bool `json:"enabled"`
JurisdictionFilter []string `json:"jurisdiction_filter,omitempty"`
AssetClassFilter []string `json:"asset_class_filter,omitempty"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Alert is a rule hit — one per transaction per rule.
type Alert struct {
ID string `json:"id"`
OrgID string `json:"org_id"`
TxID string `json:"tx_id"`
RuleID string `json:"rule_id"`
RuleName string `json:"rule_name"`
Typology string `json:"typology,omitempty"`
Citations []standard.Citation `json:"citations,omitempty"`
// EvalErr records why the rule could not reach a verdict, when it could not.
// An alert carrying a failure is not a detection; it is a transaction nobody
// has assessed, and it is routed to review on that basis.
EvalErr string `json:"eval_error,omitempty"`
Severity string `json:"severity"`
Score float64 `json:"score"`
ScoreBreakdown map[string]float64 `json:"score_breakdown,omitempty"`
ActionTaken string `json:"action_taken"`
ReviewedBy string `json:"reviewed_by,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
Decision string `json:"decision,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Causes explains an alert a model raised: the features that influenced the
// score and each one\'s contribution. Empty for rules, which explain themselves.
Causes []Cause `json:"causes,omitempty"`
}
// Case is a human review case.
type Case struct {
ID string `json:"id"`
OrgID string `json:"org_id"`
Number int64 `json:"number"`
Status string `json:"status"`
Severity string `json:"severity"`
EntityIDs []string `json:"entity_ids,omitempty"`
AlertIDs []string `json:"alert_ids,omitempty"`
AssigneeID string `json:"assignee_id,omitempty"`
OpenedAt time.Time `json:"opened_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
Resolution string `json:"resolution,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Assessment is the retained decision that closed the case. A case cannot be
// closed without one: a disposal with no recorded reason is not a decision.
Assessment string `json:"assessment,omitempty"`
}
// CaseEvent is a case timeline entry.
type CaseEvent struct {
ID string `json:"id"`
CaseID string `json:"case_id"`
AuthorID string `json:"author_id"`
Kind string `json:"kind"`
Body string `json:"body,omitempty"`
FilePath string `json:"file_path,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// SanctionsList is list metadata.
type SanctionsList struct {
ID string `json:"id"`
Source string `json:"source"`
URL string `json:"url"`
Format string `json:"format"`
FetchedAt time.Time `json:"fetched_at"`
SHA256 string `json:"sha256"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SanctionsEntry is a flattened sanctions list entry.
type SanctionsEntry struct {
ID string `json:"id"`
ListID string `json:"list_id"`
RefID string `json:"ref_id"`
Name string `json:"name"`
Aliases []string `json:"aliases,omitempty"`
DOB string `json:"dob,omitempty"`
Nationality string `json:"nationality,omitempty"`
Address string `json:"address,omitempty"`
Type string `json:"type"`
Raw json.RawMessage `json:"raw,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Webhook is a subscriber configuration.
type Webhook struct {
ID string `json:"id"`
OrgID string `json:"org_id"`
URL string `json:"url"`
Secret string `json:"secret"`
Events []string `json:"events"`
Enabled bool `json:"enabled"`
LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"`
FailureCount int `json:"failure_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RuleHit is the result of evaluating a single rule against a transaction.
type RuleHit struct {
Rule Rule
Match bool
Score float64
EvalErr string // non-empty if the rule evaluation failed (fail-closed)
// Causes explains a hit that came from a model rather than an expression.
// Empty for rules, which explain themselves.
Causes []Cause
}
// EvalContext is the evaluation context passed to expr-lang rules.
type EvalContext struct {
Tx Transaction `json:"tx"`
Entity Entity `json:"entity"`
}
// EvalResult is the synchronous response to a transaction ingestion.
type EvalResult struct {
Action string `json:"action"`
Score float64 `json:"score"`
AlertIDs []string `json:"alert_ids"`
CaseID string `json:"case_id,omitempty"`
}
// Cause is one feature's contribution to an alert a model raised.
//
// Where a rule is its own explanation — the expression says what fired — a
// model's output is a number, and a number on its own cannot go to an
// investigator. What is owed instead is the features that influenced the output,
// the risk associated with each, and the risk indicators behind them, and a Cause
// is exactly that owed set for one feature.
//
// Observed and Baseline are the arithmetic the score came from, in the terms Unit
// names, so the explanation is the same calculation rather than a second account
// of it. Without is the score the model would have produced had this feature been
// unremarkable, holding everything else: it is a counterfactual on the model
// itself and it is what makes the contribution a measurement rather than an
// attribution scheme's opinion.
type Cause struct {
Feature string `json:"feature"`
Typology string `json:"typology"`
Indicator string `json:"indicator"`
Citation string `json:"citation"`
Severity string `json:"severity"`
Unit string `json:"unit"`
Observed float64 `json:"observed"`
Baseline float64 `json:"baseline"`
Without float64 `json:"without"`
// Share is this feature's part of the score, in [0,1]. Zero across every
// cause means no single feature accounts for the alert and the combination
// does; the causes are then ordered by how far each sits from unremarkable.
Share float64 `json:"share"`
}
// ActionRank orders actions by how much they demand, so that "the strongest of
// these" and "no stronger than this" are one comparison rather than two tables.
// An unknown action ranks lowest: an action nobody defined cannot be allowed to
// outrank one that was.
func ActionRank(action string) int {
switch action {
case ActionBlock:
return 4
case ActionReport:
return 3
case ActionReview:
return 2
case ActionFlag:
return 1
default:
return 0
}
}
// MaxIdent bounds every identifier a transaction carries.
//
// It is the one bound at the door, and it is what turns every count downstream
// into a figure in bytes. A sliding-aggregate key is a fixed ring plus this
// string; a field catalog name is this string; a retained record's slot is this
// string. Bounding how MANY of a thing is kept says nothing about memory unless
// something bounds how big one is, and this is that something — see
// velocity.Config, whose per-tenant ceiling is stated in bytes on the strength of
// it.
//
// 256 bytes is longer than any synthetic handle a core banking system issues and
// short enough that the products are answerable. It is deliberately not
// per-field: one number, checked in one place, is a bound somebody can hold in
// their head.
const MaxIdent = 256
// Bounded reports whether every identifier this transaction carries is inside
// [MaxIdent].
//
// Refusing is right where truncating is not: an identifier this engine shortened
// is an aggregate kept under a key that names a different account, and a
// retained record whose reference no longer resolves in the system it came from.
func (t Transaction) Bounded() error {
for name, value := range map[string]string{
"id": t.ID, "user_id": t.UserID, "account_id": t.AccountID,
"symbol": t.Symbol, "asset_class": t.AssetClass, "side": t.Side,
"currency": t.Currency, "counterparty": t.Counterparty,
"direction": t.Direction, "customer_name": t.CustomerName,
"customer_jurisdiction": t.CustomerJurisdiction,
"ip_address": t.IPAddress, "device_fingerprint": t.DeviceFingerprint,
"source": t.Source,
} {
if len(value) > MaxIdent {
return fmt.Errorf("%w: %s is %d bytes, at most %d", ErrIdent, name, len(value), MaxIdent)
}
}
return nil
}
// ErrIdent is an identifier longer than this engine accepts.
var ErrIdent = errors.New("types: identifier is longer than this engine accepts")