Compare commits
6
Commits
lsp
...
v1.801.466
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fef9142132 | ||
|
|
6739b4b497 | ||
|
|
03e99b322b | ||
|
|
ee5f8ade2b | ||
|
|
bae3cff236 | ||
|
|
074b11d984 |
+76
-6
@@ -98,7 +98,25 @@ func scoreOverPlane(ctx context.Context, lg log.Logger, org string, q cloud.Risk
|
||||
// the request path for the next caller. A socket that IS there and does not
|
||||
// answer stays an outage and still denies, which is the fact this gate exists
|
||||
// to fail closed on.
|
||||
if !scorerUp() {
|
||||
//
|
||||
// AND THE PROBE ITSELF CAN FAIL, which is a THIRD fact and not either of those
|
||||
// two. [plane.Listening] reports ENOENT and ECONNREFUSED as "no listener" and
|
||||
// returns every other dial error UNTOUCHED, precisely so that a socket which is
|
||||
// present and unusable is never read as one that is absent. Under fd exhaustion
|
||||
// (EMFILE), a mode that denies the dial (EACCES) or a run directory whose path
|
||||
// has grown past the sun_path bound (ENAMETOOLONG), the probe fails in
|
||||
// microseconds against a scorer that is perfectly healthy — so the budget below
|
||||
// never catches it, and discarding the error here would answer ABSENT and wave
|
||||
// the top-up through. That is the fail-open this gate exists to close, reachable
|
||||
// by putting the commerce process under fd pressure.
|
||||
up, err := scorerUp()
|
||||
if err != nil {
|
||||
// The judge's door is THERE and this process cannot use it. An outage, handed
|
||||
// to the seam as one — [cloud.Decide] renders it RefusalError, and the fail
|
||||
// policy denies it because the query is privileged.
|
||||
return cloud.RiskVerdict{}, fmt.Errorf("risk: the scorer's socket is unusable: %w", err)
|
||||
}
|
||||
if !up {
|
||||
wakeScorer(lg)
|
||||
return cloud.RiskUnavailable(q, cloud.RefusalAbsent), nil
|
||||
}
|
||||
@@ -155,10 +173,17 @@ func signalsOf(facts map[string]string) []plane.Signal {
|
||||
// now. It connects, because the file does not answer the question: a socket left
|
||||
// by a dead pod outlives it, and for a lazily started app that difference is the
|
||||
// whole answer.
|
||||
func scorerUp() bool {
|
||||
//
|
||||
// IT RETURNS THE ERROR, and that is the whole of it. [plane.Listening] separates
|
||||
// three facts and this is a pass-through of that separation: no listener (false,
|
||||
// nil), a listener (true, nil), and a socket that is present and UNUSABLE (false,
|
||||
// err). Folding the third into the first — `return err == nil && up` — is the one
|
||||
// line that turns an outage into an absence, and absence is the exemption the fail
|
||||
// policy grants a privileged grant. This is the same contract [plane.Reach] keeps
|
||||
// for the router's own start door, for the same reason.
|
||||
func scorerUp() (bool, error) {
|
||||
plane.Bind()
|
||||
up, err := plane.Listening(zip.SocketPath(riskpeer.App))
|
||||
return err == nil && up
|
||||
return plane.Listening(zip.SocketPath(riskpeer.App))
|
||||
}
|
||||
|
||||
// waking holds the ONE start request in flight. The host single-flights the start
|
||||
@@ -203,11 +228,18 @@ func wakeScorer(lg log.Logger) {
|
||||
// question went unanswered, and a privileged grant waits rather than proceeds.
|
||||
// It is an operational fact, so saying it is honest and useful.
|
||||
//
|
||||
// the model DECIDED against it — 403, and nothing more. A risk reason handed
|
||||
// the SCREEN DECIDED against it — 403, and nothing more. A risk reason handed
|
||||
// back to whoever triggered it is a feedback channel for tuning the next
|
||||
// attempt. The reason is written to the log with the shape and policy version
|
||||
// that produced it, which is where an operator reads it.
|
||||
//
|
||||
// WHICH OF THE TWO IT IS, IS READ OFF THE ACTION AND THE REFUSAL TOGETHER — never
|
||||
// off the refusal alone. A decision is the screen's whenever anything decided it,
|
||||
// and after the rule and the model were fused that includes a determination made
|
||||
// over stated facts while the model itself had no opinion to offer. Such a verdict
|
||||
// carries BOTH a decided action and the model's own refusal, and only the pair
|
||||
// tells it apart from the one the fail policy invented.
|
||||
//
|
||||
// It is never a 402. Out of funds is what a 402 means at this door and this is
|
||||
// not that — the whole point of the door is that the caller has no funds yet.
|
||||
func riskGate(lg log.Logger) zip.Handler {
|
||||
@@ -230,7 +262,21 @@ func riskGate(lg log.Logger) zip.Handler {
|
||||
if v.Allowed() {
|
||||
return c.Next()
|
||||
}
|
||||
if v.Refusal != "" {
|
||||
// A NO-DECISION IS A BLOCK CARRYING A REFUSAL, and both halves are the test.
|
||||
// [cloud.riskUnavailable] is the ONLY producer of that pair — it is what the
|
||||
// fail policy returns for a privileged grant the scorer could not answer — and
|
||||
// a scored verdict never carries a refusal at all. So this is the whole of "the
|
||||
// judge is here and did not answer", and nothing else reaches it.
|
||||
//
|
||||
// THE REFUSAL ALONE IS NOT THE TEST, because Action and Refusal became
|
||||
// INDEPENDENT the moment the rule and the model were fused: the severest of the
|
||||
// two stands, and the model's own refusal is carried beside it. An armed
|
||||
// organisation whose model is still warming, on a payment the rule froze,
|
||||
// answers {restrict, "warming"} — a DETERMINATION, reached from stated facts,
|
||||
// with the model merely having had no opinion to add. Read off the refusal that
|
||||
// is a 503 "try again in a moment", which invites the retry that settles the
|
||||
// payment the rule just froze, and reports a working control as an outage.
|
||||
if v.Action == cloud.ActionBlock && v.Refusal != "" {
|
||||
return zip.Errorf(http.StatusServiceUnavailable,
|
||||
"the payment screen could not answer (%s) — try again in a moment", v.Refusal)
|
||||
}
|
||||
@@ -271,6 +317,24 @@ func payerOrg(c *zip.Ctx) string {
|
||||
// The amount is the fact that matters at a credit door — value velocity is the
|
||||
// axis a stolen card moves — and it is the one signal the model reads as a
|
||||
// coordinate. The rest are the gate's record of why it asked.
|
||||
//
|
||||
// THE COUNTRY IS THE ADDRESS'S, NOT THE PAYER'S, and that is a limitation this
|
||||
// door cannot fix from here. The jurisdiction worth judging is the account's
|
||||
// billing or KYC one, and no part of it reaches this process: the top-up body is
|
||||
// a Square nonce, an amount and a currency; the card is tokenised in the browser
|
||||
// and its PAN never touches this binary, so there is no billing address to read;
|
||||
// the validated principal carries an org, a user, a name and an email and no
|
||||
// geography; and there is no customer or KYC record here holding one. Stating a
|
||||
// billing country from any of that would be inventing the one fact the rule turns
|
||||
// on.
|
||||
//
|
||||
// So this states the strongest thing that IS true — the jurisdiction our own edge
|
||||
// resolved from the connecting address, and only when the peer is one of our own
|
||||
// hops ([cloud.ClientCountry]) — and the scorer's rule is documented as reading a
|
||||
// weak signal. It is spoofable by a VPN, which means it can be evaded DOWNWARD
|
||||
// into silence; it cannot be forged upward into somebody else's freeze, and
|
||||
// silence is the state this door was already in. Wiring the billing or KYC
|
||||
// jurisdiction, when there is one to wire, replaces this signal at this one line.
|
||||
func topupSignals(c *zip.Ctx) map[string]string {
|
||||
var body struct {
|
||||
AmountCents int64 `json:"amountCents"`
|
||||
@@ -285,6 +349,12 @@ func topupSignals(c *zip.Ctx) map[string]string {
|
||||
"ip": cloud.ClientIP(c),
|
||||
"currency": strings.ToLower(strings.TrimSpace(body.Currency)),
|
||||
}
|
||||
// Omitted when nothing trustworthy stated one. An absent country is a fact the
|
||||
// rule reads as "the geography half cannot judge"; an empty string sent as a
|
||||
// value would be a gate claiming to have looked.
|
||||
if country := cloud.ClientCountry(c); country != "" {
|
||||
signals[plane.SignalCountry] = country
|
||||
}
|
||||
// NANO IS USD. A minor unit in another currency converted as though it were
|
||||
// cents would be a number the value features read as a different amount of
|
||||
// money, so an amount this gate cannot state in USD is not stated at all —
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -132,6 +133,105 @@ func TestScoreOverPlane_AnUndeployedPeerIsAbsentRatherThanAnOutage(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// unusableRuntimeDir points the fleet's socket scheme at a directory whose socket
|
||||
// paths cannot be DIALLED — the third fact [plane.Listening] separates, and the one
|
||||
// the converse controls below turn on.
|
||||
//
|
||||
// The path is longer than sun_path (108 bytes on Linux, 104 on Darwin), so the
|
||||
// address is refused while the sockaddr is built — before any syscall, in
|
||||
// microseconds, and against a scorer that may be perfectly healthy. That is the
|
||||
// shape of every real one: EMFILE once the process is out of descriptors, EACCES on
|
||||
// a run directory it may not open, ENAMETOOLONG on an over-long one. None of them
|
||||
// is ENOENT and none is ECONNREFUSED, so none of them is an ABSENCE — and none is
|
||||
// slow enough for the 150ms budget to catch.
|
||||
func unusableRuntimeDir(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("ZIP_RUNTIME_DIR", filepath.Join(t.TempDir(), strings.Repeat("d", 200)))
|
||||
plane.Unbind()
|
||||
t.Cleanup(plane.Unbind)
|
||||
}
|
||||
|
||||
// TestScoreOverPlane_AnUnusableSocketIsAnOutageRatherThanAnAbsence — THE MIRROR
|
||||
// IMAGE of [TestScoreOverPlane_AnUndeployedPeerIsAbsentRatherThanAnOutage], and
|
||||
// the control that side had no counterpart for.
|
||||
//
|
||||
// Absence and outage are the two facts the whole probe exists to tell apart, and
|
||||
// the probe reports THREE: no listener, a listener, and a socket that is present
|
||||
// and unusable. The third one is an outage. Folding it into the first — which is
|
||||
// what `err == nil && up` does — hands the fail policy the one refusal it EXEMPTS,
|
||||
// so a privileged grant at the credit door is waved through by a scorer nobody
|
||||
// could reach. Reachable without touching the risk app at all: exhaust this
|
||||
// process's descriptors and every probe answers "no scorer here".
|
||||
//
|
||||
// Mutation proof: restore `return err == nil && up` in scorerUp, or drop the error
|
||||
// branch in scoreOverPlane, and BOTH assertions below fail while
|
||||
// [TestScoreOverPlane_AnUndeployedPeerIsAbsentRatherThanAnOutage] still passes.
|
||||
func TestScoreOverPlane_AnUnusableSocketIsAnOutageRatherThanAnAbsence(t *testing.T) {
|
||||
unusableRuntimeDir(t)
|
||||
t.Cleanup(func() { cloud.SetRiskScorer(nil) })
|
||||
|
||||
q := cloud.RiskQuery{
|
||||
Stage: cloud.StagePayment,
|
||||
Subject: cloud.RiskSubject{Kind: plane.KindAccount, ID: "acme/u_412"},
|
||||
Privileged: true,
|
||||
}
|
||||
|
||||
// THE PROBE ITSELF, first: a socket that cannot be dialled is not a socket with
|
||||
// nobody behind it, and the difference is the error.
|
||||
if up, err := scorerUp(); err == nil {
|
||||
t.Fatalf("an undiallable socket probed clean (up=%v) — the fixture proves nothing", up)
|
||||
}
|
||||
|
||||
// THE SHAPE. An outage is an ERROR out of this seam. Answering it as a verdict
|
||||
// at all would be this client deciding the fail policy for itself, and the only
|
||||
// verdict it is entitled to state is the ABSENT one.
|
||||
v, err := scoreOverPlane(context.Background(), luxlog.New("gatetest"), gateOrg, q)
|
||||
if err == nil {
|
||||
t.Fatalf("an unusable socket answered %+v with no error — an unreachable-but-present "+
|
||||
"scorer is an outage, and only a NOT-DEPLOYED one may read as an absence", v)
|
||||
}
|
||||
if v.Refusal == cloud.RefusalAbsent {
|
||||
t.Errorf("refusal %q — an outage was laundered into the one refusal the fail policy exempts", v.Refusal)
|
||||
}
|
||||
|
||||
// AND THE POLICY'S ANSWER TO IT, which is the fact that decides whether money
|
||||
// moves: the query is privileged, so the seam BLOCKS rather than allows.
|
||||
installRiskScorer(luxlog.New("gatetest"))
|
||||
switch got := cloud.Decide(context.Background(), gateOrg, q); {
|
||||
case got.Action == cloud.ActionAllow:
|
||||
t.Errorf("a privileged grant was ALLOWED against an unreachable scorer (%+v) — "+
|
||||
"this is the fail-open that mints spendable balance during a risk-plane outage", got)
|
||||
case got.Action != cloud.ActionBlock:
|
||||
t.Errorf("action %q, want %q", got.Action, cloud.ActionBlock)
|
||||
case got.Refusal != cloud.RefusalError:
|
||||
t.Errorf("refusal %q, want %q — the record must name the operational fact", got.Refusal, cloud.RefusalError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRiskGate_AnUnusableScorerSocketMakesTheGrantWait is that same property at
|
||||
// the WIRE, where the money is: the door answers 503 and the charge does not
|
||||
// settle.
|
||||
//
|
||||
// Mutation proof: restore `return err == nil && up` in scorerUp and this answers
|
||||
// 200 — the top-up settles, unscored, during a risk-plane failure.
|
||||
func TestRiskGate_AnUnusableScorerSocketMakesTheGrantWait(t *testing.T) {
|
||||
app := gateApp(t)
|
||||
unusableRuntimeDir(t)
|
||||
installRiskScorer(luxlog.New("gatetest"))
|
||||
|
||||
code, body := topup(t, app)
|
||||
if code == http.StatusOK {
|
||||
t.Fatalf("the top-up SETTLED against an unreachable scorer: %d %s — a stolen card clears "+
|
||||
"whenever this process runs out of descriptors", code, body)
|
||||
}
|
||||
if code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("%d %s, want 503 — the judge's door is there and unusable, so a privileged grant waits", code, body)
|
||||
}
|
||||
if !strings.Contains(body, cloud.RefusalError) {
|
||||
t.Errorf("the refusal does not name the operational fact a customer can act on: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRiskGate_APresentScorerThatCannotAnswerMakesTheGrantWait is the other half
|
||||
// of the fail policy, and the reason Privileged is stated at this gate at all:
|
||||
// cloud.Privileged() does not match this route, so without the explicit bit a
|
||||
@@ -185,6 +285,57 @@ func TestRiskGate_AVerdictDecidesTheOutcome(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRiskGate_ADeterminationIsNotAnOutage — a DECIDED verdict that also carries a
|
||||
// refusal is the screen's answer, never the fail policy's.
|
||||
//
|
||||
// Action and Refusal became INDEPENDENT when the rule and the model were fused:
|
||||
// the severest of the two judgements stands, and the model's own refusal is
|
||||
// recorded beside it rather than replaced by it. So an armed organisation whose
|
||||
// model is still warming, on a payment a stated rule froze, answers
|
||||
// {restrict, "warming"} — a determination over stated facts, with the model merely
|
||||
// having had nothing to add.
|
||||
//
|
||||
// Read off the REFUSAL ALONE that is a 503 "try again in a moment": the door
|
||||
// invites the retry that settles the payment it just froze, and reports a control
|
||||
// working exactly as designed as an outage. The discriminator is the pair.
|
||||
//
|
||||
// Mutation proof: change the branch back to `if v.Refusal != ""` and the first two
|
||||
// cases answer 503; drop the `v.Refusal != ""` conjunct and the third answers 403,
|
||||
// which loses the one operational fact a customer can act on.
|
||||
func TestRiskGate_ADeterminationIsNotAnOutage(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
action string
|
||||
refusal string
|
||||
want int
|
||||
}{
|
||||
{"a rule froze it while the model was warming", cloud.ActionRestrict, "warming", http.StatusForbidden},
|
||||
{"a rule froze it while the model was unusable", cloud.ActionRestrict, "unusable", http.StatusForbidden},
|
||||
// The fuse keeps REVIEW proceeding whatever the model had to say, so a
|
||||
// determination that only summons a person still serves the customer.
|
||||
{"a rule examined it while the model was warming", cloud.ActionReview, "warming", http.StatusOK},
|
||||
// The ONE shape the fail policy produces, and the only one that is a 503.
|
||||
// BLOCK is what makes it identifiable: the scorer's own vocabulary tops out
|
||||
// at restrict, held closed by
|
||||
// [TestActions_TheScorerNeverBlocks] in apps/risk.
|
||||
{"the judge is here and did not answer", cloud.ActionBlock, cloud.RefusalError, http.StatusServiceUnavailable},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
app := gateApp(t)
|
||||
cloud.SetRiskScorer(func(context.Context, string, cloud.RiskQuery) (cloud.RiskVerdict, error) {
|
||||
return cloud.RiskVerdict{Action: tc.action, Refusal: tc.refusal, Score: 0.9}, nil
|
||||
})
|
||||
code, body := topup(t, app)
|
||||
if code != tc.want {
|
||||
t.Fatalf("{%s, %q}: %d %s, want %d", tc.action, tc.refusal, code, body, tc.want)
|
||||
}
|
||||
if tc.want == http.StatusForbidden && strings.Contains(body, "try again") {
|
||||
t.Errorf("a determination was answered as a retryable outage: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRiskGate_JudgesThePayerThatWillBeCredited pins the QUESTION rather than the
|
||||
// answer: the moment, the subject, the amount, and the bit that decides which way
|
||||
// silence falls.
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
package risk
|
||||
|
||||
// determine.go — the DETERMINATION: a rule over stated facts, beside a model over
|
||||
// learned mass.
|
||||
//
|
||||
// # Why there is a second judge at all
|
||||
//
|
||||
// [learn.go]'s model answers one question — is this where this organisation's
|
||||
// behaviour normally lives — and it answers it from that organisation's own
|
||||
// history. That is the right question and it has a blind spot with a name: a
|
||||
// FRESH account has no history, so the model is warming, so it declines, so the
|
||||
// event is judged by nothing at all. The credit door
|
||||
// (apps/commerce/risk.go) is exactly where that blind spot is expensive: a
|
||||
// settled card charge mints spendable balance, and the first payment on a fresh
|
||||
// account is the one the model can never have an opinion about.
|
||||
//
|
||||
// A ten-million-dollar top-up from a jurisdiction no anti-money-laundering
|
||||
// supervision reaches is not anomalous — it is unprecedented, which scores as
|
||||
// nothing, which is the point. It does not need a model. It needs a rule.
|
||||
//
|
||||
// # The two judges are INDEPENDENT, and that is the whole property
|
||||
//
|
||||
// This rule does not read the score, the cut, the shape or whether the model has
|
||||
// warmed. It reads two stated facts — where the payer acted from and how much
|
||||
// moves — and reaches its own verdict. So a warming model, a refusing model and a
|
||||
// model that has never been planted all leave it untouched: [fuse] takes the
|
||||
// SEVEREST of the two verdicts, and a model with no opinion contributes an allow,
|
||||
// which cannot lower anything.
|
||||
//
|
||||
// That inverts the previous arrangement, where the model's refusal WAS the
|
||||
// answer. It is also why the fusion is worst-of rather than a weighted blend: two
|
||||
// judges of different kinds do not average into a third opinion, and a control
|
||||
// that a single lenient judge can switch off is not a control.
|
||||
//
|
||||
// # It is a RISK tier, and never a sanctions determination
|
||||
//
|
||||
// A formal designation is a legal finding about a named party, made by the
|
||||
// screening engine that holds the designations (luxfi/aml pkg/screen, published
|
||||
// at /v1/aml). This binary does not link it and does not make one — the AML plane
|
||||
// is its own deployment reached over the wire, so there is no in-process screen to
|
||||
// defer to, and a rule that CALLED one would put a network hop on the credit
|
||||
// door's request path inside a 150ms budget. What this reads instead is the
|
||||
// jurisdiction LISTING ([policy.go]'s [jurisdictions]), which is a statement about
|
||||
// a country rather than a finding about a person. Naming the difference is not
|
||||
// pedantry: it is what keeps a risk control from being read as a legal conclusion
|
||||
// it did not make.
|
||||
//
|
||||
// # It does not name a stage
|
||||
//
|
||||
// A jurisdiction is a fact about an event, not about a lifecycle moment, so
|
||||
// nothing here branches on one. An event that moves no money simply does not
|
||||
// reach the value half — the geography half is the whole rule for a signup, and
|
||||
// that is the correct reading rather than a special case.
|
||||
//
|
||||
// # Three halves, two kinds of fact
|
||||
//
|
||||
// [onEvent] decides on the EVENT's own stated facts: where the payer acted from
|
||||
// and how much moves. [onPace] and [onFan] decide on the organisation's OWN
|
||||
// AGGREGATES — what those identifiers had already done when this event arrived —
|
||||
// which is a fact about history that no single event can carry. They are the two
|
||||
// halves a point-in-time test structurally cannot make:
|
||||
//
|
||||
// PACE several payments, each unremarkable, arriving faster or accruing
|
||||
// further than one payment is allowed to. Splitting a payment to stay
|
||||
// under a threshold is the typology the value half alone can never see,
|
||||
// because every piece of it is legal by construction.
|
||||
// FAN-OUT one device or one counterparty tying together subjects that are
|
||||
// nominally unrelated. Account farming looks like ordinary behaviour
|
||||
// from every account taken by itself, and like one actor from above.
|
||||
//
|
||||
// All three read stated bounds and none of them reads the model, so [severest]
|
||||
// composes them the same way [fuse] composes the result with the model's: the
|
||||
// severest stands, every finding is reported, and a judge with no opinion cannot
|
||||
// lower another's.
|
||||
|
||||
import (
|
||||
"github.com/hanzoai/cloud"
|
||||
contract "github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
// The tiers [reference.Jurisdictions] answers with. Its package returns them as
|
||||
// bare strings and publishes no constants, so they are spelled here once rather
|
||||
// than at each comparison — a misspelling would silently place every country in
|
||||
// neither tier, which reads as a world with nothing risky in it.
|
||||
const (
|
||||
// tierAction — countermeasures are called for. This tier may freeze.
|
||||
tierAction = "action"
|
||||
// tierMonitoring — increased monitoring. This tier may examine, no further.
|
||||
tierMonitoring = "monitoring"
|
||||
)
|
||||
|
||||
// The reasons a determination carries, in the same terse voice as the model's own
|
||||
// ([causeAboveCut] and its siblings) and in one place for the same reason: a
|
||||
// sentence that drifted between two call sites would be two facts in the log.
|
||||
const (
|
||||
// causeCountermeasuresValue — both halves fired: a jurisdiction the listing
|
||||
// calls for countermeasures on, moving at least [freezeNano]. This is the only
|
||||
// determination that takes an outcome past review.
|
||||
causeCountermeasuresValue = "a jurisdiction called for countermeasures, at this value"
|
||||
// causeCountermeasures — the geography alone, at any value.
|
||||
causeCountermeasures = "a jurisdiction called for countermeasures"
|
||||
// causeMonitored — a jurisdiction under increased monitoring, at any value.
|
||||
causeMonitored = "a jurisdiction under increased monitoring"
|
||||
// causeValue — the value alone, from a jurisdiction carrying no signal.
|
||||
causeValue = "a value past the examining threshold"
|
||||
// causeUnplaced — a value past the freeze from a jurisdiction that could not
|
||||
// be assessed at all. It is deliberately NOT the same as "not listed": an
|
||||
// unusable listing must be loud, never silently clean.
|
||||
causeUnplaced = "a value past the freeze, from a jurisdiction that could not be placed"
|
||||
)
|
||||
|
||||
// The reasons the AGGREGATE halves carry. Each names the finding and is completed
|
||||
// with the identifier it was found on, which comes from a closed set (ring.go) and
|
||||
// never from a caller — an operator reading a frozen payment has to be told which
|
||||
// of the three the bound was reached on, because the answer selects the
|
||||
// investigation.
|
||||
const (
|
||||
// causeBurstValue — both halves of the pace rule fired: more events than the
|
||||
// stated bound inside the burst window, accruing at least [freezeNano]. This
|
||||
// is the only aggregate determination that takes an outcome past review.
|
||||
causeBurstValue = "a burst of events accruing past the freeze, for this "
|
||||
// causeAccrued — the accrued value alone, past the examining threshold. It is
|
||||
// the value half applied to a WINDOW rather than to one event, which is what
|
||||
// makes a payment split into pieces visible at all.
|
||||
causeAccrued = "a value accrued past the examining threshold, for this "
|
||||
// causeBurst — the count alone, at any value.
|
||||
causeBurst = "more events in the burst window than the stated bound, for this "
|
||||
// causeShared — one identifier tying together more distinct subjects than the
|
||||
// stated bound.
|
||||
causeShared = "more distinct subjects than the stated bound share this "
|
||||
)
|
||||
|
||||
// determination is what the rule concluded: an action from cloud's vocabulary and
|
||||
// the reason that names which half of the rule reached it. The zero value is an
|
||||
// allow with nothing to say, which is what "the rule found nothing" means.
|
||||
type determination struct {
|
||||
Action string
|
||||
Cause string
|
||||
}
|
||||
|
||||
// fired reports whether the rule reached anything at all. An allow from this rule
|
||||
// is the absence of a finding, not a clean bill of health — the model's answer is
|
||||
// what stands in that case.
|
||||
func (d determination) fired() bool { return d.Action != "" && d.Action != cloud.ActionAllow }
|
||||
|
||||
// determine is the DETERMINATION: the severest of what this event's own stated
|
||||
// facts say about it and what this organisation's own aggregates already held
|
||||
// about its identifiers.
|
||||
//
|
||||
// seen is [plane.prior]'s reading. Its ZERO VALUE is "the aggregates said
|
||||
// nothing", which leaves the two aggregate halves silent and the event's own
|
||||
// facts the whole rule — the correct reading for a subject with no history, and
|
||||
// the one that keeps the geography determination exactly what it was.
|
||||
func determine(country string, nano int64, seen reading) determination {
|
||||
return severest(onEvent(country, nano), onPace(seen), onFan(seen))
|
||||
}
|
||||
|
||||
// onEvent judges one event against the stated bounds: where the payer acted
|
||||
// from, and how much moves.
|
||||
//
|
||||
// The two halves are ordered because they are not symmetric. Geography decides
|
||||
// FIRST and alone where it can, because a jurisdiction the listing calls for
|
||||
// countermeasures on is a finding at any value; the value half is what remains
|
||||
// for the jurisdictions the listing says nothing about. Only the ACTION tier may
|
||||
// take an outcome past review, which is the listing's own distinction honoured
|
||||
// rather than restated: the two tiers exist because the required response
|
||||
// differs, and collapsing them would lose exactly the choice this rule has to
|
||||
// make.
|
||||
//
|
||||
// country is ISO 3166-1 alpha-2, or empty when the asking gate could not state
|
||||
// one. Empty is ABSENT and not "somewhere unremarkable": the geography half
|
||||
// simply does not run, the value half still does, and nothing here invents a
|
||||
// jurisdiction from silence.
|
||||
func onEvent(country string, nano int64) determination {
|
||||
if d, ok := onGeography(country, nano); ok {
|
||||
return d
|
||||
}
|
||||
if nano >= reviewNano {
|
||||
return determination{cloud.ActionReview, causeValue}
|
||||
}
|
||||
return determination{Action: cloud.ActionAllow}
|
||||
}
|
||||
|
||||
// onGeography is the geography half, and it reports whether it decided at all.
|
||||
// Separating the halves is what makes each one testable against its own bound
|
||||
// instead of through the other.
|
||||
//
|
||||
// A listing that CANNOT ANSWER is not a listing that answered "no". The reference
|
||||
// package refuses an empty or undated listing for precisely that reason, and the
|
||||
// refusal is honoured here rather than swallowed: a large value from a
|
||||
// jurisdiction nobody could place is examined and says so. Below that value the
|
||||
// rule declines to decide on geography and leaves the event to the value half —
|
||||
// escalating every payment on a broken listing would be a control that takes the
|
||||
// product down instead of defending it.
|
||||
func onGeography(country string, nano int64) (determination, bool) {
|
||||
tier, err := jurisdictions().Jurisdiction(country)
|
||||
if err != nil {
|
||||
if nano >= freezeNano {
|
||||
return determination{cloud.ActionReview, causeUnplaced}, true
|
||||
}
|
||||
return determination{}, false
|
||||
}
|
||||
switch {
|
||||
case tier == tierAction && nano >= freezeNano:
|
||||
// The one path past review. A statistical judgement may not reach here on
|
||||
// its own (cloud's vocabulary says so) — but this is not one: it is a
|
||||
// stated rule over stated facts, which is the kind of determination the
|
||||
// vocabulary reserves the severer actions for.
|
||||
//
|
||||
// RESTRICT AND NOT BLOCK. Restrict is "proceeds at a reduced ceiling", and
|
||||
// at this door the reduced ceiling is zero — the top-up does not settle —
|
||||
// while block is reserved for a finding that this party may not transact
|
||||
// at all. That finding is the AML plane's to make about a person; this one
|
||||
// is about a country and a number, so it freezes the payment and summons a
|
||||
// person rather than declaring the payer prohibited.
|
||||
return determination{cloud.ActionRestrict, causeCountermeasuresValue}, true
|
||||
case tier == tierAction:
|
||||
return determination{cloud.ActionReview, causeCountermeasures}, true
|
||||
case tier == tierMonitoring:
|
||||
return determination{cloud.ActionReview, causeMonitored}, true
|
||||
}
|
||||
return determination{}, false
|
||||
}
|
||||
|
||||
// onPace is the VELOCITY half: what one of this event's identifiers had already
|
||||
// done inside the aggregates' burst window.
|
||||
//
|
||||
// WHY THE VALUE HALF IS NOT ENOUGH ON ITS OWN. [onEvent] bounds what ONE payment
|
||||
// may move, so the way past it is arithmetic available to anyone: send five
|
||||
// payments of eleven thousand instead of one of fifty-five. Every piece is under
|
||||
// the bound by construction, so no point-in-time test can see it — which is
|
||||
// precisely the typology the aggregates exist for (velocity's own package prose
|
||||
// names it, and the EBA names it as "split to circumvent reporting limits").
|
||||
//
|
||||
// SO THE VALUE BOUNDS ARE THE SAME NUMBERS, READ OVER A WINDOW. [reviewNano] and
|
||||
// [freezeNano] are not restated here with different values: what one payment may
|
||||
// not move, an hour of payments may not move either. One statement of the
|
||||
// organisation's value appetite, two readings of it — a second pair of numbers
|
||||
// would be a second appetite nobody stated.
|
||||
//
|
||||
// THE COUNT NEEDS ITS OWN BOUND because there is no existing one: a thousand
|
||||
// one-dollar top-ups is a finding about the count and about nothing else.
|
||||
// [burstEvents] is it.
|
||||
//
|
||||
// THE FREEZE TIER IS A CONJUNCTION, exactly as the geography half's is. Geography
|
||||
// may freeze only where the ACTION tier meets the freeze value; pace may freeze
|
||||
// only where a burst meets the freeze value. A count alone never freezes — a busy
|
||||
// hour is not a determination that anything is wrong — and an accrual alone never
|
||||
// does either, because [reviewNano] is a reason to look and says so.
|
||||
//
|
||||
// It reads EVERY axis the event names and takes the severest, because an
|
||||
// identifier is not more or less suspicious for being the subject rather than the
|
||||
// device: the bound is about one identifier doing too much, whichever one it is.
|
||||
func onPace(seen reading) determination {
|
||||
out := determination{}
|
||||
for _, w := range seen.Pace {
|
||||
switch {
|
||||
case w.Events >= burstEvents && w.Nano >= freezeNano:
|
||||
// The one aggregate path past review, and it is a STATED rule over stated
|
||||
// facts rather than a statistical judgement — which is the kind of
|
||||
// determination cloud's vocabulary reserves the severer actions for.
|
||||
//
|
||||
// RESTRICT AND NOT BLOCK, for [onGeography]'s reason: the reduced ceiling at
|
||||
// this door is zero, and a finding that a party may not transact at all is
|
||||
// the AML plane's to make about a person. This is about a count and a sum.
|
||||
out = severest(out, determination{cloud.ActionRestrict, causeBurstValue + w.Axis})
|
||||
case w.Nano >= reviewNano:
|
||||
out = severest(out, determination{cloud.ActionReview, causeAccrued + w.Axis})
|
||||
case w.Events >= burstEvents:
|
||||
out = severest(out, determination{cloud.ActionReview, causeBurst + w.Axis})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// onFan is the FAN-OUT half: how many distinct subjects one of this event's LINK
|
||||
// identifiers — its device, its counterparty — already ties together.
|
||||
//
|
||||
// WHAT IT SEES THAT NOTHING ELSE DOES. Account farming is unremarkable from every
|
||||
// account taken by itself: each one signs up once, tops up once, behaves once.
|
||||
// The only place the pattern exists is in what the accounts SHARE, and a rule
|
||||
// that reads one subject's history at a time is looking at the wrong subject. The
|
||||
// engine's own feature inventory says the same of this axis — "activity across a
|
||||
// network of connected persons rather than one customer" — and the fan-out is
|
||||
// that network stated as a number.
|
||||
//
|
||||
// REVIEW AND NO FURTHER, at any count. A shared device is evidence about a
|
||||
// relationship and not about a payment: a household, a shared office and a farm
|
||||
// are the same shape from here, and only a person can tell them apart. Review
|
||||
// PROCEEDS ([cloud.RiskVerdict.Allowed]) — the customer is served and the finding
|
||||
// is on the record — which is what makes it the right and the only ceiling for a
|
||||
// signal this ambiguous. That is also why the count bound is generous rather than
|
||||
// tight, and why the counterparty is read at the same bound as the device: a
|
||||
// popular merchant and a collection account are indistinguishable from a count
|
||||
// alone, so the only defensible response to either is a look.
|
||||
func onFan(seen reading) determination {
|
||||
out := determination{}
|
||||
for _, s := range seen.Shared {
|
||||
if s.Subjects >= fanSubjects {
|
||||
out = severest(out, determination{cloud.ActionReview, causeShared + s.Axis})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// severest composes several half-rules into the one determination they reach
|
||||
// together.
|
||||
//
|
||||
// THE SEVEREST STANDS, for the reason [fuse] takes the severest of the rule and
|
||||
// the model: these halves read different facts, so there is no average of them
|
||||
// that means anything, and taking the milder would let a half that found nothing
|
||||
// switch off a half that found something. It is the same composition one level
|
||||
// down, which is why it is the same word.
|
||||
//
|
||||
// EVERY FINDING IS REPORTED and the one that SET the action leads, again for
|
||||
// [fuse]'s reason: they are separate findings about one event and an operator
|
||||
// reading the record has to see all of them. A half that did not fire contributes
|
||||
// no reason, because "nothing found" is not a finding.
|
||||
//
|
||||
// WITH NOTHING FIRED the mildest recognised action still stands, so an allow from
|
||||
// a half that RAN is not lost behind the empty determination of a half that had
|
||||
// no reading to run on. An unrecognised action ranks below allow
|
||||
// ([cloud.Severity]), so it can never become the answer.
|
||||
func severest(ds ...determination) determination {
|
||||
lead := -1
|
||||
for i, d := range ds {
|
||||
if d.fired() && (lead < 0 || cloud.Severity(d.Action) > cloud.Severity(ds[lead].Action)) {
|
||||
lead = i
|
||||
}
|
||||
}
|
||||
if lead < 0 {
|
||||
out := determination{}
|
||||
for _, d := range ds {
|
||||
if cloud.Severity(d.Action) > cloud.Severity(out.Action) {
|
||||
out = d
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := ds[lead]
|
||||
for i, d := range ds {
|
||||
if i != lead && d.fired() {
|
||||
out.Cause += "; " + d.Cause
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fuse composes the two judgements into the one answer a gate receives.
|
||||
//
|
||||
// THE SEVEREST STANDS ([cloud.Severity]). The two judges are of different kinds —
|
||||
// one reads learned mass, the other reads stated facts — so there is no average
|
||||
// of them that means anything, and taking the milder would let either one switch
|
||||
// the other off. A model with no opinion contributes an allow, which is why a
|
||||
// warming model cannot lower a determination.
|
||||
//
|
||||
// THE RULE'S REASON IS RECORDED WHENEVER IT FIRED, even when the model's verdict
|
||||
// was already the severer one and even in shadow. The two are separate findings
|
||||
// about one event and an operator reading the record has to be able to see both,
|
||||
// so the model's own reason is kept BESIDE the rule's rather than replaced by it.
|
||||
//
|
||||
// SHADOW CHANGES THE OUTCOME BACK, AND ONLY THE OUTCOME. An organisation whose
|
||||
// model is in shadow has not armed anything, and a determination that froze a
|
||||
// payment there would be a control nobody reviewed acting on real money. So the
|
||||
// action is left exactly as the model left it and the cause says what WOULD have
|
||||
// happened — which is the same arrangement [answer] already makes for an
|
||||
// above-the-cut score in shadow ([causeShadowCut]), for the same reason. It is
|
||||
// the whole value of a shadow deployment: the finding, on real traffic, changing
|
||||
// nothing.
|
||||
func fuse(out *contract.RiskDecided, det determination, shadow bool) *contract.RiskDecided {
|
||||
if !det.fired() {
|
||||
return out
|
||||
}
|
||||
if out.Cause != "" {
|
||||
out.Cause = det.Cause + "; " + out.Cause
|
||||
} else {
|
||||
out.Cause = det.Cause
|
||||
}
|
||||
if shadow {
|
||||
out.Cause += ", in shadow"
|
||||
return out
|
||||
}
|
||||
if cloud.Severity(det.Action) > cloud.Severity(out.Action) {
|
||||
out.Action = det.Action
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
package risk
|
||||
|
||||
// determine_test.go — the rule that does not need the model, held to the one
|
||||
// property it exists for: a fresh account's first large payment from a listed
|
||||
// jurisdiction is stopped, and it is stopped WHILE THE MODEL HAS NO OPINION.
|
||||
//
|
||||
// That is the whole cut. Every other case here exists to show the rule is not
|
||||
// simply refusing everything: fifty dollars from an unlisted jurisdiction still
|
||||
// allows, and shadow still changes nothing at all.
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/anomaly"
|
||||
"github.com/luxfi/aml/pkg/velocity"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
contract "github.com/hanzoai/cloud/plane"
|
||||
)
|
||||
|
||||
// tenMillion is the payment the deliverable names, in the unit the wire carries.
|
||||
const tenMillion = 10_000_000 * nanoPerUSD
|
||||
|
||||
// warming is the assessment the engine returns for an account it has never seen:
|
||||
// a POPULATED score it has no confidence in, Scored false, and the reason. It is
|
||||
// the state a fresh account is in at its first payment, and the state in which
|
||||
// every judgement used to fall through to allow.
|
||||
func warming(shadow bool) decided {
|
||||
return decided{
|
||||
A: anomaly.Assessment{
|
||||
Scored: false, Reason: anomaly.ReasonWarming,
|
||||
// 0.0 exactly: the deliverable's own wording, and the worst case — a
|
||||
// fusion that read the score would read this as maximally normal.
|
||||
Score: 0.0, Shadow: shadow,
|
||||
},
|
||||
Version: 0, Shape: "halfspace:fresh",
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermine_AFreshLargePaymentFromAListedJurisdictionIsFrozen is THE test.
|
||||
//
|
||||
// Ten million dollars, from a jurisdiction the listing calls for countermeasures
|
||||
// on, on an account the model has never seen — score 0.0, warming, no opinion.
|
||||
// The old arrangement answered allow, because the model's refusal WAS the answer.
|
||||
//
|
||||
// Mutation proof: make [fuse] read d.A.Scored before applying the determination,
|
||||
// or take the milder of the two verdicts instead of the severer, and this fails
|
||||
// with action=allow. Drop `AF` from [defaultJurisdictions] and it fails with
|
||||
// review — the value alone.
|
||||
func TestDetermine_AFreshLargePaymentFromAListedJurisdictionIsFrozen(t *testing.T) {
|
||||
got := fuse(answer(warming(false)), determine("AF", tenMillion, reading{}), false)
|
||||
|
||||
if got.Action != cloud.ActionRestrict {
|
||||
t.Fatalf("action %q, want %q — a fresh account's first ten-million-dollar top-up from a "+
|
||||
"jurisdiction called for countermeasures proceeded because the MODEL had no opinion; "+
|
||||
"the rule beside it is what must not need one", got.Action, cloud.ActionRestrict)
|
||||
}
|
||||
// The cause has to name BOTH halves, because an operator reading this row is
|
||||
// being told why a payment was frozen and "geography" alone would not say.
|
||||
for _, want := range []string{"jurisdiction", "countermeasures", "value"} {
|
||||
if !strings.Contains(got.Cause, want) {
|
||||
t.Errorf("cause %q does not name %q — the reason a payment was frozen must state "+
|
||||
"which facts froze it", got.Cause, want)
|
||||
}
|
||||
}
|
||||
// The refusal still travels, and still carries no score. The rule decided the
|
||||
// outcome; it did not turn a declining model into a confident one.
|
||||
if got.Refusal != anomaly.ReasonWarming {
|
||||
t.Errorf("refusal %q, want %q — the model's own state is not erased by a rule that "+
|
||||
"overruled its silence", got.Refusal, anomaly.ReasonWarming)
|
||||
}
|
||||
if got.Score != 0 {
|
||||
t.Errorf("score %v travelled with a refusal", got.Score)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermine_TheRuleIsIndependentOfTheModelsWarmth — the same determination,
|
||||
// against every state the engine can be in, INCLUDING the three refusals. A model
|
||||
// with no opinion contributes an allow to the fusion and an allow cannot lower
|
||||
// anything, which is the property stated as a table rather than argued.
|
||||
//
|
||||
// Mutation proof: return early from [fuse] when the assessment is unscored and
|
||||
// every refusal row fails.
|
||||
func TestDetermine_TheRuleIsIndependentOfTheModelsWarmth(t *testing.T) {
|
||||
for _, a := range []anomaly.Assessment{
|
||||
{Scored: false, Reason: anomaly.ReasonWarming, Score: 0.0},
|
||||
{Scored: false, Reason: anomaly.ReasonUnusable, Score: 0.0},
|
||||
{Scored: false, Reason: anomaly.ReasonUnidentified, Score: 0.0},
|
||||
{Scored: true, Score: 0.0, Cut: 0.5}, // scored, and maximally normal
|
||||
{Scored: true, Score: 0.9, Cut: 0.5, Alert: true}, // scored, and already alerting
|
||||
} {
|
||||
got := fuse(answer(decided{A: a, Shape: "halfspace:x"}), determine("AF", tenMillion, reading{}), false)
|
||||
if got.Action != cloud.ActionRestrict {
|
||||
t.Errorf("model state %+v: action %q, want %q — the determination is not the model's "+
|
||||
"to soften", a, got.Action, cloud.ActionRestrict)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermine_TheRuleItself, over the bounds it is stated in. Each row is one
|
||||
// branch, and together they are the whole rule.
|
||||
func TestDetermine_TheRuleItself(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
country string
|
||||
nano int64
|
||||
action string
|
||||
cause string
|
||||
}{{
|
||||
name: "a small payment from an unlisted jurisdiction is the ordinary case",
|
||||
// The negative control. A rule that freezes this is a rule nobody can ship.
|
||||
country: "US", nano: 50 * nanoPerUSD,
|
||||
action: cloud.ActionAllow,
|
||||
}, {
|
||||
name: "the countermeasures tier at or above the freeze",
|
||||
country: "AF", nano: freezeNano,
|
||||
action: cloud.ActionRestrict, cause: causeCountermeasuresValue,
|
||||
}, {
|
||||
name: "the countermeasures tier below the freeze is still examined",
|
||||
country: "AF", nano: 50 * nanoPerUSD,
|
||||
action: cloud.ActionReview, cause: causeCountermeasures,
|
||||
}, {
|
||||
name: "the monitoring tier is examined at any value, and never frozen",
|
||||
// The listing keeps two tiers because the required response differs. A
|
||||
// monitored jurisdiction moving ten million is looked at, not frozen.
|
||||
country: "HT", nano: tenMillion,
|
||||
action: cloud.ActionReview, cause: causeMonitored,
|
||||
}, {
|
||||
name: "an unlisted jurisdiction moving a very large value is examined",
|
||||
country: "US", nano: tenMillion,
|
||||
action: cloud.ActionReview, cause: causeValue,
|
||||
}, {
|
||||
name: "just below the examining threshold, nothing fires",
|
||||
country: "US", nano: reviewNano - 1,
|
||||
action: cloud.ActionAllow,
|
||||
}, {
|
||||
name: "a jurisdiction the gate could not state leaves the value half to decide",
|
||||
// Absent is ABSENT. The geography half does not run and does not invent a
|
||||
// verdict; the value half still does.
|
||||
country: "", nano: tenMillion,
|
||||
action: cloud.ActionReview, cause: causeValue,
|
||||
}, {
|
||||
name: "a jurisdiction the gate could not state, at an ordinary value, is the ordinary case",
|
||||
country: "", nano: 50 * nanoPerUSD,
|
||||
action: cloud.ActionAllow,
|
||||
}, {
|
||||
name: "the code is read case-insensitively",
|
||||
// The edge states upper case and the listing is upper case, but a gate that
|
||||
// stated "af" must not silently fall out of the tier.
|
||||
country: "af", nano: tenMillion,
|
||||
action: cloud.ActionRestrict, cause: causeCountermeasuresValue,
|
||||
}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := determine(tc.country, tc.nano, reading{})
|
||||
if got.Action != tc.action {
|
||||
t.Errorf("action %q, want %q", got.Action, tc.action)
|
||||
}
|
||||
if tc.cause != "" && got.Cause != tc.cause {
|
||||
t.Errorf("cause %q, want %q", got.Cause, tc.cause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermine_ShadowRecordsTheFindingAndChangesNothing.
|
||||
//
|
||||
// An organisation that has not armed its model has not armed this rule either.
|
||||
// The finding is COMPUTED and RECORDED — that is what a shadow deployment is for
|
||||
// — and the outcome is left exactly where the model left it.
|
||||
//
|
||||
// Mutation proof: drop the shadow branch from [fuse] and the action becomes
|
||||
// restrict for an organisation that reviewed nothing.
|
||||
func TestDetermine_ShadowRecordsTheFindingAndChangesNothing(t *testing.T) {
|
||||
got := fuse(answer(warming(true)), determine("AF", tenMillion, reading{}), true)
|
||||
|
||||
if got.Action != cloud.ActionAllow {
|
||||
t.Fatalf("action %q, want %q — a control nobody reviewed froze real money",
|
||||
got.Action, cloud.ActionAllow)
|
||||
}
|
||||
if !strings.Contains(got.Cause, "in shadow") {
|
||||
t.Errorf("cause %q does not say the finding was in shadow — an allow that looks clean "+
|
||||
"is exactly what a shadow deployment must not produce", got.Cause)
|
||||
}
|
||||
if !strings.Contains(got.Cause, "countermeasures") {
|
||||
t.Errorf("cause %q does not carry the finding at all — shadow reports what it WOULD "+
|
||||
"have done; it does not stay silent", got.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermine_TheModelsOwnReasonSurvivesTheFusion. Two judges, two findings,
|
||||
// one event — and an operator has to see both. The rule's reason leads because it
|
||||
// is the one that set the action.
|
||||
func TestDetermine_TheModelsOwnReasonSurvivesTheFusion(t *testing.T) {
|
||||
alerting := decided{A: anomaly.Assessment{Scored: true, Score: 0.9, Cut: 0.5, Alert: true}}
|
||||
got := fuse(answer(alerting), determine("AF", tenMillion, reading{}), false)
|
||||
|
||||
if !strings.Contains(got.Cause, causeCountermeasuresValue) {
|
||||
t.Errorf("cause %q lost the rule's finding", got.Cause)
|
||||
}
|
||||
if !strings.Contains(got.Cause, causeAboveCut) {
|
||||
t.Errorf("cause %q replaced the model's finding instead of keeping it beside the rule's — "+
|
||||
"they are two findings about one event", got.Cause)
|
||||
}
|
||||
if got.Action != cloud.ActionRestrict {
|
||||
t.Errorf("action %q, want the severer of the two (%q)", got.Action, cloud.ActionRestrict)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermine_ANonFiringRuleLeavesTheModelsAnswerWhole — the rule must be
|
||||
// invisible when it finds nothing. This is what keeps the ordinary path exactly
|
||||
// as it was.
|
||||
func TestDetermine_ANonFiringRuleLeavesTheModelsAnswerWhole(t *testing.T) {
|
||||
for _, d := range []decided{
|
||||
warming(false),
|
||||
{A: anomaly.Assessment{Scored: true, Score: 0.9, Cut: 0.5, Alert: true}},
|
||||
{A: anomaly.Assessment{Scored: true, Score: 0.2, Cut: 0.5}},
|
||||
} {
|
||||
want := answer(d)
|
||||
got := fuse(answer(d), determine("US", 50*nanoPerUSD, reading{}), d.A.Shadow)
|
||||
if *got != *want {
|
||||
t.Errorf("a rule that found nothing changed the answer:\n got %+v\nwant %+v", *got, *want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermine_TheStatedBoundsCannotDisableTheRule.
|
||||
//
|
||||
// The thresholds are policy, and a policy that read as zero would not be a
|
||||
// permissive setting — it would be the rule silently switched off in one
|
||||
// direction and firing on everything in the other. Both are refused HERE, where a
|
||||
// number is changed, rather than discovered at a credit door.
|
||||
func TestDetermine_TheStatedBoundsCannotDisableTheRule(t *testing.T) {
|
||||
switch {
|
||||
case freezeNano <= 0:
|
||||
t.Error("the freeze threshold is not positive, so every payment reaches it and the rule " +
|
||||
"freezes the product")
|
||||
case reviewNano <= 0:
|
||||
t.Error("the examining threshold is not positive, so every payment is examined")
|
||||
case freezeNano > reviewNano:
|
||||
t.Error("the freeze threshold is above the examining one, so a value that freezes a listed " +
|
||||
"jurisdiction would not even be examined from an unlisted one")
|
||||
}
|
||||
// And the listing itself must be able to answer. An empty or undated one
|
||||
// reports every country on earth as unlisted, which is a world with nothing
|
||||
// risky in it and nobody would notice.
|
||||
if _, err := jurisdictions().Jurisdiction("AF"); err != nil {
|
||||
t.Fatalf("the jurisdiction listing cannot assess any country: %v", err)
|
||||
}
|
||||
if tier, _ := jurisdictions().Jurisdiction("AF"); tier != tierAction {
|
||||
t.Errorf("AF is in tier %q, want %q — the deliverable's own jurisdiction", tier, tierAction)
|
||||
}
|
||||
if tier, _ := jurisdictions().Jurisdiction("US"); tier != "" {
|
||||
t.Errorf("US is in tier %q, want no tier — a listing that lists everywhere lists nowhere", tier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermine_OverTheWire is the same deliverable through the op a gate
|
||||
// actually calls, on an ARMED organisation, with a model that has learned
|
||||
// nothing. It is what proves the signal name, the amount and the posture are
|
||||
// wired end to end rather than only inside the pure functions above.
|
||||
//
|
||||
// Mutation proof: misspell [contract.SignalCountry] at either end and this fails
|
||||
// with action=allow — the exact silent failure the shared spelling exists to
|
||||
// prevent.
|
||||
func TestDetermine_OverTheWire(t *testing.T) {
|
||||
probe.reset(true)
|
||||
mountApp(t)
|
||||
|
||||
arm(t, mounted.State.plane, key(t, brandA, orgA))
|
||||
|
||||
out, err := planeDecide(asPeer(orgA), &contract.RiskDecideIn{
|
||||
Stage: cloud.StagePayment, Kind: contract.KindAccount, Subject: "u_fresh",
|
||||
Signals: []contract.Signal{
|
||||
{Name: contract.SignalNano, Value: "10000000000000000"}, // ten million USD
|
||||
{Name: contract.SignalCountry, Value: "AF"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("planeDecide: %v", err)
|
||||
}
|
||||
if out.Refusal == "" {
|
||||
t.Fatal("the model answered with an opinion — this test is meaningless unless it is warming")
|
||||
}
|
||||
if out.Action != cloud.ActionRestrict {
|
||||
t.Fatalf("action %q, want %q — over the wire, a fresh account's ten-million-dollar top-up "+
|
||||
"from a listed jurisdiction was allowed", out.Action, cloud.ActionRestrict)
|
||||
}
|
||||
if !strings.Contains(out.Cause, "countermeasures") || !strings.Contains(out.Cause, "value") {
|
||||
t.Errorf("cause %q does not name the geography and the value", out.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermine_OverTheWireInShadowIsUnchanged — the same call against an
|
||||
// organisation that armed nothing. This is the state EVERY organisation is in
|
||||
// today, so it is the state that must be proven harmless.
|
||||
func TestDetermine_OverTheWireInShadowIsUnchanged(t *testing.T) {
|
||||
probe.reset(true)
|
||||
mountApp(t)
|
||||
|
||||
out, err := planeDecide(asPeer(orgA), &contract.RiskDecideIn{
|
||||
Stage: cloud.StagePayment, Kind: contract.KindAccount, Subject: "u_fresh",
|
||||
Signals: []contract.Signal{
|
||||
{Name: contract.SignalNano, Value: "10000000000000000"},
|
||||
{Name: contract.SignalCountry, Value: "AF"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("planeDecide: %v", err)
|
||||
}
|
||||
if out.Action != cloud.ActionAllow {
|
||||
t.Fatalf("action %q, want %q — an organisation that armed nothing had a payment frozen",
|
||||
out.Action, cloud.ActionAllow)
|
||||
}
|
||||
if !strings.Contains(out.Cause, "in shadow") {
|
||||
t.Errorf("cause %q does not report the finding it would have acted on", out.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActions_TheScorerNeverBlocks — this app's whole vocabulary tops out at
|
||||
// RESTRICT, and that is load-bearing OUTSIDE this app.
|
||||
//
|
||||
// Two rules meet here and both already say it in prose. [answer]: "an alert is a
|
||||
// review, never a block — a statistical judgement may reach here and no further on
|
||||
// its own". [onGeography]: "restrict and not block — block is reserved for a
|
||||
// finding that this party may not transact at all", which is the AML plane's to
|
||||
// make about a person and not this one's about a country and a number.
|
||||
//
|
||||
// WHAT DEPENDS ON IT. The credit door tells a DETERMINATION apart from a
|
||||
// no-decision by the pair (action == block AND a refusal), because
|
||||
// [cloud.riskUnavailable] is then the only thing that can have produced it. Emit
|
||||
// block from here — with a warming model's refusal still riding along, which the
|
||||
// fuse deliberately preserves — and that gate reads a working freeze as a scorer
|
||||
// outage and answers 503 "try again in a moment": an invitation to retry the
|
||||
// payment it just froze.
|
||||
//
|
||||
// It is STRUCTURAL rather than a sample of decisions, because the claim is "no
|
||||
// path", and no finite set of scored events can establish that. Comments are
|
||||
// invisible to it — the prose above names block freely; only a resolved reference
|
||||
// counts.
|
||||
func TestActions_TheScorerNeverBlocks(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool {
|
||||
return !strings.HasSuffix(fi.Name(), "_test.go")
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse the package: %v", err)
|
||||
}
|
||||
var found int
|
||||
for _, pkg := range pkgs {
|
||||
for name, file := range pkg.Files {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
sel, ok := n.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
id, ok := sel.X.(*ast.Ident)
|
||||
if !ok || id.Name != "cloud" {
|
||||
return true
|
||||
}
|
||||
switch sel.Sel.Name {
|
||||
case "ActionBlock", "ActionChallenge":
|
||||
found++
|
||||
t.Errorf("%s:%d names cloud.%s — this app's vocabulary is allow, review and "+
|
||||
"restrict, and the credit door reads a block CARRYING a refusal as the fail "+
|
||||
"policy's no-decision rather than as a determination",
|
||||
name, fset.Position(sel.Pos()).Line, sel.Sel.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
// The scan must actually be looking at this package: a filter that matched no
|
||||
// file would pass the assertion above vacuously.
|
||||
if len(pkgs) == 0 {
|
||||
t.Fatal("the scan parsed no package at all — the assertion above proves nothing")
|
||||
}
|
||||
for _, pkg := range pkgs {
|
||||
if len(pkg.Files) < 2 {
|
||||
t.Fatalf("the scan parsed %d file(s) of package %s — the assertion above proves nothing",
|
||||
len(pkg.Files), pkg.Name)
|
||||
}
|
||||
}
|
||||
// And it must be able to SEE the vocabulary it is looking for, or a renamed
|
||||
// import would silently disarm it.
|
||||
if _, err := os.Stat("determine.go"); err != nil {
|
||||
t.Fatalf("determine.go: %v", err)
|
||||
}
|
||||
if !usesCloudAction(t, fset, pkgs) {
|
||||
t.Error("no file resolves any cloud.Action* at all — the scan cannot see the vocabulary " +
|
||||
"it is policing, so its silence means nothing")
|
||||
}
|
||||
_ = found
|
||||
}
|
||||
|
||||
// usesCloudAction reports whether the package names ANY cloud.Action* constant, so
|
||||
// the scan above is known to be able to see one.
|
||||
func usesCloudAction(t *testing.T, fset *token.FileSet, pkgs map[string]*ast.Package) bool {
|
||||
t.Helper()
|
||||
for _, pkg := range pkgs {
|
||||
for _, file := range pkg.Files {
|
||||
var seen bool
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
sel, ok := n.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if id, ok := sel.X.(*ast.Ident); ok && id.Name == "cloud" &&
|
||||
strings.HasPrefix(sel.Sel.Name, "Action") {
|
||||
seen = true
|
||||
}
|
||||
return true
|
||||
})
|
||||
if seen {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── the aggregate halves ─────────────────────────────────────────────────────
|
||||
//
|
||||
// The event's own facts are what the geography half decides on, and a payment
|
||||
// split into pieces defeats them by construction: every piece is under the bound
|
||||
// because that is what splitting it is for. These two halves decide on what the
|
||||
// organisation's aggregates ALREADY HELD about the identifiers carrying it, which
|
||||
// is the only place the pattern exists.
|
||||
|
||||
// burst is a reading of one axis at a stated count and accrued value, in the
|
||||
// shape [plane.prior] produces.
|
||||
func burst(axis string, events int, usd float64) reading {
|
||||
return reading{Pace: []paced{{Axis: axis, Events: events, Nano: nanoOfUSD(usd), Span: time.Hour}}}
|
||||
}
|
||||
|
||||
// TestPace_ABurstIsReviewedAndAFundedBurstIsFrozen — the whole velocity rule, over
|
||||
// the bounds it is stated in. Each row is one branch.
|
||||
//
|
||||
// Mutation proof: drop the count branch and the burst rows fall to allow; drop the
|
||||
// conjunction from the freeze branch and a busy hour moving pocket change freezes
|
||||
// a customer.
|
||||
func TestPace_ABurstIsReviewedAndAFundedBurstIsFrozen(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
seen reading
|
||||
action string
|
||||
cause string
|
||||
}{{
|
||||
name: "one ordinary payment on a subject with an ordinary history",
|
||||
// The negative control. A rule that reviews this is a rule nobody can ship.
|
||||
seen: burst(axisSubject, 3, 300),
|
||||
action: cloud.ActionAllow,
|
||||
}, {
|
||||
name: "just under the burst bound, nothing fires",
|
||||
seen: burst(axisSubject, burstEvents-1, 300),
|
||||
action: cloud.ActionAllow,
|
||||
}, {
|
||||
name: "a burst at an ordinary value is examined",
|
||||
seen: burst(axisSubject, burstEvents, 300),
|
||||
action: cloud.ActionReview, cause: causeBurst + axisSubject,
|
||||
}, {
|
||||
name: "a value ACCRUED past the examining threshold is examined, at any count",
|
||||
// The typology the value half cannot see: five payments of eleven thousand
|
||||
// are each under [reviewNano] and together are not.
|
||||
seen: burst(axisSubject, 5, 55_000),
|
||||
action: cloud.ActionReview, cause: causeAccrued + axisSubject,
|
||||
}, {
|
||||
name: "a burst accruing past the freeze is frozen",
|
||||
seen: burst(axisSubject, burstEvents, 12_000),
|
||||
action: cloud.ActionRestrict, cause: causeBurstValue + axisSubject,
|
||||
}, {
|
||||
name: "an accrual past the freeze but under the burst count is only examined",
|
||||
// The conjunction, held from the other side: money alone never freezes, for
|
||||
// the same reason [reviewNano] is a look and not a refusal.
|
||||
seen: burst(axisSubject, 2, 60_000),
|
||||
action: cloud.ActionReview, cause: causeAccrued + axisSubject,
|
||||
}, {
|
||||
name: "the bound is about ONE identifier and not about the subject",
|
||||
// The device carrying a burst is the same finding as the subject carrying
|
||||
// one, and it names the device so an investigator looks at the right thing.
|
||||
seen: burst(axisDevice, burstEvents, 300),
|
||||
action: cloud.ActionReview, cause: causeBurst + axisDevice,
|
||||
}, {
|
||||
name: "a counterparty pair carrying a funded burst is frozen and says so",
|
||||
seen: burst(axisPair, burstEvents, 12_000),
|
||||
action: cloud.ActionRestrict, cause: causeBurstValue + axisPair,
|
||||
}, {
|
||||
name: "aggregates that said nothing decide nothing",
|
||||
// The reading a fresh subject produces. It must be silence and never a
|
||||
// finding, or every first payment is a finding.
|
||||
seen: reading{},
|
||||
action: cloud.ActionAllow,
|
||||
}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := onPace(tc.seen); got.fired() != (tc.action != cloud.ActionAllow) {
|
||||
t.Fatalf("the pace half %v on %+v", got, tc.seen)
|
||||
}
|
||||
got := determine("US", 50*nanoPerUSD, tc.seen)
|
||||
if got.Action != tc.action {
|
||||
t.Errorf("action %q, want %q", got.Action, tc.action)
|
||||
}
|
||||
if tc.cause != "" && got.Cause != tc.cause {
|
||||
t.Errorf("cause %q, want %q", got.Cause, tc.cause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFan_AnIdentifierSharedAcrossSubjectsIsExamined — the fan-out rule, over its
|
||||
// one bound and both identifiers it reads.
|
||||
//
|
||||
// Mutation proof: delete the comparison and the shared rows fall to allow; change
|
||||
// it to `>` and the row AT the bound stops firing.
|
||||
func TestFan_AnIdentifierSharedAcrossSubjectsIsExamined(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
seen reading
|
||||
action string
|
||||
cause string
|
||||
}{{
|
||||
name: "a device a household shares is the ordinary case",
|
||||
// The negative control, and the reason the bound is generous.
|
||||
seen: reading{Shared: []shared{{Axis: axisDevice, Subjects: 4}}},
|
||||
action: cloud.ActionAllow,
|
||||
}, {
|
||||
name: "just under the bound, nothing fires",
|
||||
seen: reading{Shared: []shared{{Axis: axisDevice, Subjects: fanSubjects - 1}}},
|
||||
action: cloud.ActionAllow,
|
||||
}, {
|
||||
name: "a device tying together more subjects than the bound is examined",
|
||||
seen: reading{Shared: []shared{{Axis: axisDevice, Subjects: fanSubjects}}},
|
||||
action: cloud.ActionReview, cause: causeShared + axisDevice,
|
||||
}, {
|
||||
name: "a counterparty tying together more subjects than the bound is examined",
|
||||
seen: reading{Shared: []shared{{Axis: axisPeer, Subjects: fanSubjects}}},
|
||||
action: cloud.ActionReview, cause: causeShared + axisPeer,
|
||||
}, {
|
||||
name: "and it is examined and NEVER frozen, however wide the network",
|
||||
// A shared identifier is evidence about a relationship, not about a payment.
|
||||
// A household, an office and a farm are the same shape from here.
|
||||
seen: reading{Shared: []shared{{Axis: axisDevice, Subjects: 100 * fanSubjects}}},
|
||||
action: cloud.ActionReview, cause: causeShared + axisDevice,
|
||||
}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := determine("US", 50*nanoPerUSD, tc.seen)
|
||||
if got.Action != tc.action {
|
||||
t.Errorf("action %q, want %q", got.Action, tc.action)
|
||||
}
|
||||
if tc.cause != "" && got.Cause != tc.cause {
|
||||
t.Errorf("cause %q, want %q", got.Cause, tc.cause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregates_AreIndependentOfTheModelsWarmth is the property both halves exist
|
||||
// for, stated as a table against every state the engine can be in.
|
||||
//
|
||||
// A fresh account is exactly where account farming and card testing live, and a
|
||||
// fresh account's model is warming — so a rule these could not fire under a
|
||||
// refusal would be a rule that is off precisely where it is needed. A model with
|
||||
// no opinion contributes an allow to the fusion and an allow cannot lower
|
||||
// anything.
|
||||
//
|
||||
// Mutation proof: return early from [fuse] when the assessment is unscored, or
|
||||
// read d.A.Scored anywhere in [determine], and every refusal row fails.
|
||||
func TestAggregates_AreIndependentOfTheModelsWarmth(t *testing.T) {
|
||||
for _, seen := range []struct {
|
||||
name string
|
||||
seen reading
|
||||
action string
|
||||
}{
|
||||
{"a funded burst", burst(axisSubject, burstEvents, 12_000), cloud.ActionRestrict},
|
||||
{"a burst on a device", burst(axisDevice, burstEvents, 10), cloud.ActionReview},
|
||||
{"a shared device", reading{Shared: []shared{{Axis: axisDevice, Subjects: fanSubjects}}}, cloud.ActionReview},
|
||||
} {
|
||||
for _, a := range []anomaly.Assessment{
|
||||
{Scored: false, Reason: anomaly.ReasonWarming, Score: 0.0},
|
||||
{Scored: false, Reason: anomaly.ReasonUnusable, Score: 0.0},
|
||||
{Scored: false, Reason: anomaly.ReasonUnidentified, Score: 0.0},
|
||||
{Scored: true, Score: 0.0, Cut: 0.5}, // scored, and maximally normal
|
||||
{Scored: true, Score: 0.9, Cut: 0.5, Alert: true}, // scored, and already alerting
|
||||
} {
|
||||
// The unlisted jurisdiction at an ordinary value is deliberate: the
|
||||
// geography half finds NOTHING here, so the action can only have come from
|
||||
// the aggregate half under test.
|
||||
got := fuse(answer(decided{A: a, Shape: "halfspace:x"}),
|
||||
determine("US", 50*nanoPerUSD, seen.seen), false)
|
||||
if got.Action != seen.action {
|
||||
t.Errorf("%s, model state %+v: action %q, want %q — the aggregate determination "+
|
||||
"is not the model's to soften", seen.name, a, got.Action, seen.action)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregates_ShadowRecordsTheFindingAndChangesNothing. An organisation that
|
||||
// has not armed its model has not armed these rules either: the finding is
|
||||
// COMPUTED and RECORDED, and the outcome is left exactly where the model left it.
|
||||
//
|
||||
// This is the state EVERY organisation is in today, so it is the state that must
|
||||
// be proven harmless. Mutation proof: drop the shadow branch from [fuse] and a
|
||||
// control nobody reviewed freezes real money.
|
||||
func TestAggregates_ShadowRecordsTheFindingAndChangesNothing(t *testing.T) {
|
||||
got := fuse(answer(warming(true)), determine("US", 50*nanoPerUSD,
|
||||
burst(axisSubject, burstEvents, 12_000)), true)
|
||||
|
||||
if got.Action != cloud.ActionAllow {
|
||||
t.Fatalf("action %q, want %q — a control nobody reviewed froze real money",
|
||||
got.Action, cloud.ActionAllow)
|
||||
}
|
||||
if !strings.Contains(got.Cause, "in shadow") {
|
||||
t.Errorf("cause %q does not say the finding was in shadow", got.Cause)
|
||||
}
|
||||
if !strings.Contains(got.Cause, "burst") {
|
||||
t.Errorf("cause %q does not carry the finding at all — shadow reports what it WOULD "+
|
||||
"have done; it does not stay silent", got.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermine_TheSeverestOfTheThreeHalvesStandsAndEveryFindingIsReported.
|
||||
//
|
||||
// Three judges of different kinds reach one answer. Taking the milder would let a
|
||||
// half that found nothing switch off a half that found something, and dropping a
|
||||
// reason would leave an operator reading one finding about an event that produced
|
||||
// three.
|
||||
func TestDetermine_TheSeverestOfTheThreeHalvesStandsAndEveryFindingIsReported(t *testing.T) {
|
||||
// A monitored jurisdiction (review), a funded burst (restrict) and a shared
|
||||
// device (review), on one event.
|
||||
seen := burst(axisSubject, burstEvents, 12_000)
|
||||
seen.Shared = []shared{{Axis: axisDevice, Subjects: fanSubjects}}
|
||||
got := determine("HT", 50*nanoPerUSD, seen)
|
||||
|
||||
if got.Action != cloud.ActionRestrict {
|
||||
t.Fatalf("action %q, want the severest of the three (%q)", got.Action, cloud.ActionRestrict)
|
||||
}
|
||||
// The reason that SET the action leads, and the others stand beside it.
|
||||
if !strings.HasPrefix(got.Cause, causeBurstValue+axisSubject) {
|
||||
t.Errorf("cause %q does not lead with the finding that set the action", got.Cause)
|
||||
}
|
||||
for _, want := range []string{causeMonitored, causeShared + axisDevice} {
|
||||
if !strings.Contains(got.Cause, want) {
|
||||
t.Errorf("cause %q lost the finding %q — three findings about one event, and an "+
|
||||
"operator has to see all of them", got.Cause, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermine_TheAggregateBoundsCannotDisableTheRules.
|
||||
//
|
||||
// The bounds are policy, and a policy that read as zero would not be a permissive
|
||||
// setting — it would be the rule firing on everything in one direction and
|
||||
// silently switched off in the other. Both are refused HERE, where a number is
|
||||
// changed, rather than discovered at a credit door.
|
||||
func TestDetermine_TheAggregateBoundsCannotDisableTheRules(t *testing.T) {
|
||||
// THE COUNT MUST BE ABOVE WHAT A FOLD ALONE PRODUCES. A tenant's own surface
|
||||
// folds in one observation per (subject, featureBucket); at or under that
|
||||
// figure the rule fires on every continuously active customer, and on this
|
||||
// organisation's own history the moment a residency rebuilds.
|
||||
perWindow := int(burstWindow() / featureBucket)
|
||||
switch {
|
||||
case burstEvents <= 0:
|
||||
t.Error("the burst bound is not positive, so every event is a burst and the rule reviews " +
|
||||
"the whole product")
|
||||
case burstEvents <= perWindow:
|
||||
t.Errorf("the burst bound (%d) is at or under what a FOLD alone puts in the burst window "+
|
||||
"(%d = %s / %s), so this organisation's own history trips it",
|
||||
burstEvents, perWindow, burstWindow(), featureBucket)
|
||||
case burstEvents >= recordRows:
|
||||
t.Errorf("the burst bound (%d) is past what the tenant's retained record can hold (%d), "+
|
||||
"so no traffic can ever reach it and the rule is off with nothing to see",
|
||||
burstEvents, recordRows)
|
||||
}
|
||||
// THE FAN MUST BE ABOVE ONE. One distinct subject is EVERY identifier, so a
|
||||
// bound of one turns "shared" into "exists"; a bound of zero is also the
|
||||
// query's own LIMIT, which returns nothing and switches the rule off.
|
||||
switch {
|
||||
case fanSubjects <= 1:
|
||||
t.Error("the fan-out bound is one or less, so every device and every counterparty is " +
|
||||
"shared and the rule reviews the whole product")
|
||||
case fanSubjects >= recordRows:
|
||||
t.Errorf("the fan-out bound (%d) is past what the tenant's retained record can hold (%d), "+
|
||||
"so it can never be reached and the rule is off with nothing to see", fanSubjects, recordRows)
|
||||
}
|
||||
// And the aggregates must actually KEEP the window the count is read over, or
|
||||
// every reading is a zero that looks exactly like a quiet subject.
|
||||
if _, kept := newRings().pace(velocity.Key{OrgID: "o", Kind: anomaly.AxisAccount, Value: "s"}); !kept {
|
||||
t.Fatal("the aggregates keep no window, so the burst bound is read over nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// burstWindow is the window [onPace]'s count bound is actually read over: the
|
||||
// narrowest one a fresh ring set keeps. It is MEASURED from the rings rather than
|
||||
// restated, because the whole point of taking the window instead of naming it is
|
||||
// that no second statement of it can drift.
|
||||
func burstWindow() time.Duration {
|
||||
w, _ := newRings().pace(velocity.Key{OrgID: "o", Kind: anomaly.AxisAccount, Value: "s"})
|
||||
return w.Span
|
||||
}
|
||||
|
||||
// TestPace_OverTheWire is the velocity deliverable through the op a gate actually
|
||||
// calls, on an ARMED organisation, against REAL aggregates filled by that
|
||||
// organisation's own learn door — and with a model that has learned far too
|
||||
// little to have an opinion.
|
||||
//
|
||||
// It is what proves the reading, the axes and the bounds are wired end to end
|
||||
// rather than only inside the pure functions above.
|
||||
//
|
||||
// Mutation proof: drop [plane.prior] from [planeDecide] and this fails with
|
||||
// action=allow; read the widest window instead of the narrowest and the burst
|
||||
// dissolves into a month.
|
||||
func TestPace_OverTheWire(t *testing.T) {
|
||||
probe.reset(true)
|
||||
mountApp(t)
|
||||
p := mounted.State.plane
|
||||
holdFolds(t, p)
|
||||
k := key(t, brandA, orgA)
|
||||
arm(t, p, k)
|
||||
|
||||
// A funded burst on ONE subject: past the count bound, accruing past the freeze
|
||||
// and nowhere near the examining threshold, so only the conjunction can fire.
|
||||
at := time.Now().UTC().Add(-10 * time.Minute)
|
||||
batch := make([]observation, 0, burstEvents)
|
||||
for i := 0; i < burstEvents; i++ {
|
||||
batch = append(batch, ob(t, "burst_"+itoa(i), kindAccount, "u_fast", 200,
|
||||
at.Add(time.Duration(i)*time.Second)))
|
||||
}
|
||||
if _, err := p.learn(k, batch...); err != nil {
|
||||
t.Fatalf("learn: %v", err)
|
||||
}
|
||||
|
||||
out, err := planeDecide(asPeer(orgA), &contract.RiskDecideIn{
|
||||
Stage: cloud.StagePayment, Kind: contract.KindAccount, Subject: "u_fast",
|
||||
// One more ordinary payment. Its own value is far under every stated bound,
|
||||
// so the event's own facts find nothing and the aggregates are the whole rule.
|
||||
Signals: []contract.Signal{{Name: contract.SignalNano, Value: "200000000000"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("planeDecide: %v", err)
|
||||
}
|
||||
if out.Refusal == "" {
|
||||
t.Fatal("the model answered with an opinion — this test is meaningless unless it is warming")
|
||||
}
|
||||
if out.Action != cloud.ActionRestrict {
|
||||
t.Fatalf("action %q, want %q — over the wire, sixty payments accruing twelve thousand "+
|
||||
"dollars in ten minutes were allowed because the MODEL had no opinion",
|
||||
out.Action, cloud.ActionRestrict)
|
||||
}
|
||||
if !strings.Contains(out.Cause, "burst") || !strings.Contains(out.Cause, axisSubject) {
|
||||
t.Errorf("cause %q does not name the burst and the identifier it was found on", out.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFan_OverTheWire is the fan-out deliverable through the same op: twenty
|
||||
// nominally unrelated subjects, one device, and a first ordinary payment from the
|
||||
// twenty-first.
|
||||
//
|
||||
// Every one of those accounts is unremarkable taken by itself, which is the whole
|
||||
// point — the finding exists only in what they share.
|
||||
//
|
||||
// Mutation proof: drop the device from [plane.prior]'s links and this fails with
|
||||
// action=allow; count the subjects without DISTINCT and it fires on one busy
|
||||
// account.
|
||||
func TestFan_OverTheWire(t *testing.T) {
|
||||
probe.reset(true)
|
||||
mountApp(t)
|
||||
p := mounted.State.plane
|
||||
holdFolds(t, p)
|
||||
k := key(t, brandA, orgA)
|
||||
arm(t, p, k)
|
||||
|
||||
// One event each, from [fanSubjects] distinct subjects, all on one device. The
|
||||
// count is far under the burst bound and the value far under every value bound,
|
||||
// so nothing but the fan-out can fire.
|
||||
at := time.Now().UTC().Add(-time.Hour)
|
||||
batch := make([]observation, 0, fanSubjects)
|
||||
for i := 0; i < fanSubjects; i++ {
|
||||
batch = append(batch, ob(t, "farm_"+itoa(i), kindAccount, "u_farm_"+itoa(i), 5,
|
||||
at.Add(time.Duration(i)*time.Second), "", "d_shared"))
|
||||
}
|
||||
if _, err := p.learn(k, batch...); err != nil {
|
||||
t.Fatalf("learn: %v", err)
|
||||
}
|
||||
|
||||
out, err := planeDecide(asPeer(orgA), &contract.RiskDecideIn{
|
||||
Stage: cloud.StagePayment, Kind: contract.KindAccount, Subject: "u_farm_next",
|
||||
Signals: []contract.Signal{
|
||||
{Name: contract.SignalNano, Value: "5000000000"},
|
||||
{Name: contract.SignalDevice, Value: "d_shared"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("planeDecide: %v", err)
|
||||
}
|
||||
if out.Refusal == "" {
|
||||
t.Fatal("the model answered with an opinion — this test is meaningless unless it is warming")
|
||||
}
|
||||
if out.Action != cloud.ActionReview {
|
||||
t.Fatalf("action %q, want %q — over the wire, the twenty-first account on one device "+
|
||||
"was allowed", out.Action, cloud.ActionReview)
|
||||
}
|
||||
if !strings.Contains(out.Cause, axisDevice) {
|
||||
t.Errorf("cause %q does not name the device the subjects share", out.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAggregates_OverTheWireAnOrdinaryEventIsAllowed is the negative control for
|
||||
// both halves, and it is the row that makes every assertion above mean something:
|
||||
// the same armed organisation, the same door, real aggregates holding real
|
||||
// history, and one ordinary low-value payment ALLOWS.
|
||||
//
|
||||
// A rule that reviewed this is a rule nobody can ship.
|
||||
func TestAggregates_OverTheWireAnOrdinaryEventIsAllowed(t *testing.T) {
|
||||
probe.reset(true)
|
||||
mountApp(t)
|
||||
p := mounted.State.plane
|
||||
holdFolds(t, p)
|
||||
k := key(t, brandA, orgA)
|
||||
arm(t, p, k)
|
||||
|
||||
// An ordinary customer: a handful of payments, its own device, its own
|
||||
// counterparty. Everything below every stated bound.
|
||||
at := time.Now().UTC().Add(-30 * time.Minute)
|
||||
batch := make([]observation, 0, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
batch = append(batch, ob(t, "ok_"+itoa(i), kindAccount, "u_ordinary", 120,
|
||||
at.Add(time.Duration(i)*time.Minute), "merchant", "d_own"))
|
||||
}
|
||||
if _, err := p.learn(k, batch...); err != nil {
|
||||
t.Fatalf("learn: %v", err)
|
||||
}
|
||||
|
||||
out, err := planeDecide(asPeer(orgA), &contract.RiskDecideIn{
|
||||
Stage: cloud.StagePayment, Kind: contract.KindAccount, Subject: "u_ordinary",
|
||||
Signals: []contract.Signal{
|
||||
{Name: contract.SignalNano, Value: "120000000000"}, // one hundred and twenty dollars
|
||||
{Name: contract.SignalCountry, Value: "US"},
|
||||
{Name: contract.SignalDevice, Value: "d_own"},
|
||||
{Name: contract.SignalPeer, Value: "merchant"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("planeDecide: %v", err)
|
||||
}
|
||||
if out.Action != cloud.ActionAllow {
|
||||
t.Fatalf("action %q, want %q — an ordinary customer's ordinary payment was stopped "+
|
||||
"(cause %q)", out.Action, cloud.ActionAllow, out.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// arm states a live regime on ONE TEST TENANT, and nothing else. It states that
|
||||
// regime on this test's own temporary data directory; it is not a deployment, and
|
||||
// no organisation anywhere is armed by it.
|
||||
func arm(t *testing.T, p *plane, k tenant) {
|
||||
t.Helper()
|
||||
if _, err := p.appetite(k, 0.02, 0.10, true /* live */, "u_"+orgA); err != nil {
|
||||
t.Fatalf("appetite: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeverity_OrdersTheVocabularyMostPermissiveFirst. The fusion is only correct
|
||||
// if this is, and the constants' own comment is the specification.
|
||||
func TestSeverity_OrdersTheVocabularyMostPermissiveFirst(t *testing.T) {
|
||||
order := []string{
|
||||
cloud.ActionAllow, cloud.ActionReview, cloud.ActionChallenge,
|
||||
cloud.ActionRestrict, cloud.ActionBlock,
|
||||
}
|
||||
for i := 1; i < len(order); i++ {
|
||||
if cloud.Severity(order[i]) <= cloud.Severity(order[i-1]) {
|
||||
t.Errorf("%q does not rank above %q", order[i], order[i-1])
|
||||
}
|
||||
}
|
||||
// An action outside the vocabulary must never win a fusion.
|
||||
for _, bad := range []string{"", "allowed", "BLOCK", "deny"} {
|
||||
if cloud.Severity(bad) >= cloud.Severity(cloud.ActionAllow) {
|
||||
t.Errorf("unrecognised action %q ranks at or above allow, so a typo could become "+
|
||||
"the outcome", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,3 +138,80 @@ func BenchmarkLearn(b *testing.B) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPrior — what the AGGREGATE READING costs, against a record filled to
|
||||
// its own retention bound.
|
||||
//
|
||||
// WHY IT IS MEASURED AND NOT ASSUMED. [plane.prior] is on the decide path, and
|
||||
// the credit door that calls it decides inside a 150ms budget. The pace half is
|
||||
// three ring reads and is a fixed number of adds; the fan-out half is TWO SQL
|
||||
// COUNTS, and the row a `device` predicate selects on is not in the record's
|
||||
// covering index — so the read is a range scan of the tenant's own recent rows,
|
||||
// and how many that is depends on how full the record is.
|
||||
//
|
||||
// So the case measured is the WORST one the bound admits: a record at
|
||||
// [recordRows], which is [recordBudget] of observations, all inside the retention
|
||||
// window, with the queried device present on none of them — the non-firing case,
|
||||
// which is both the common one and the one that cannot stop early.
|
||||
//
|
||||
// A LIMIT does not help there: DISTINCT under a LIMIT stops as soon as the bound
|
||||
// is reached, and a device nobody shares never reaches it. That is the honest
|
||||
// shape of the cost and it is why the figure below is the one to watch.
|
||||
//
|
||||
// 28,556 rows (the ceiling) 6.3 ms/op 2,976 B 58 allocs
|
||||
//
|
||||
// Six milliseconds is 4% of the door's budget for the WORST record the bound
|
||||
// admits, and it is why there is no second index: [maxRowBytes] is measured
|
||||
// against a real row ([TestBounds_ArePublishedInTheDimensionThatBinds]) and
|
||||
// covers ONE index, so buying this read a covering index would spend the record's
|
||||
// published per-tenant ceiling to save four per cent of a budget. If that trade
|
||||
// ever changes, this benchmark is the number that says so.
|
||||
//
|
||||
// Re-measure with:
|
||||
//
|
||||
// go test -tags "sqlite_purego sqlite_math_functions sqlite_fts5" \
|
||||
// -run XXX -bench BenchmarkPrior -benchtime 50x -count 3 ./apps/risk/
|
||||
func BenchmarkPrior(b *testing.B) {
|
||||
p, k := warmForBench(b)
|
||||
at := time.Now().UTC().Add(-time.Hour)
|
||||
// Fill the record to its own ceiling, every row carrying a device and a
|
||||
// counterparty so both queries have rows to walk past.
|
||||
for held := recorded(b, p, k); held < recordRows; held = recorded(b, p, k) {
|
||||
batch := make([]observation, 0, 512)
|
||||
for j := 0; j < 512; j++ {
|
||||
n := held + j
|
||||
o, err := observe("fill_"+strconv.Itoa(n), actor{
|
||||
Kind: kindAccount, Subject: "u_" + strconv.Itoa(n%1024),
|
||||
Peer: "p_" + strconv.Itoa(n%512), Device: "d_" + strconv.Itoa(n%512),
|
||||
}, 1, at.Add(time.Duration(n)*time.Millisecond))
|
||||
if err != nil {
|
||||
b.Fatalf("observe: %v", err)
|
||||
}
|
||||
batch = append(batch, o)
|
||||
}
|
||||
if _, err := p.learn(k, batch...); err != nil {
|
||||
b.Fatalf("fill: %v", err)
|
||||
}
|
||||
}
|
||||
rows := recorded(b, p, k)
|
||||
// The event judged names a device and a counterparty NOBODY shares, so neither
|
||||
// count can stop early: the whole retained range is walked.
|
||||
one, err := observe("probe", actor{
|
||||
Kind: kindAccount, Subject: "u_probe", Peer: "p_probe", Device: "d_probe",
|
||||
}, 1, at)
|
||||
if err != nil {
|
||||
b.Fatalf("observe: %v", err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
seen, err := p.prior(k, one)
|
||||
if err != nil {
|
||||
b.Fatalf("prior: %v", err)
|
||||
}
|
||||
if len(seen.Pace) == 0 || len(seen.Shared) != 2 {
|
||||
b.Fatalf("the reading is %+v — the benchmark is measuring the wrong path", seen)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(rows), "rows")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package risk
|
||||
|
||||
// listing_test.go — the jurisdiction listing the geography half of the rule
|
||||
// decides against, held to the one property that decides whether the rule works at
|
||||
// all: it must be able to ANSWER.
|
||||
//
|
||||
// [reference.Jurisdictions] refuses an empty or undated listing, and [onGeography]
|
||||
// swallows that refusal by design — a broken listing must not escalate every
|
||||
// payment. Those two are each right on their own, and together they make a listing
|
||||
// that cannot answer indistinguishable, from the outside, from a world with nothing
|
||||
// risky in it. So which listing is in force, and whether it can decide, is settled
|
||||
// where the listing is CHOSEN and reported where an operator can read it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/reference"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
)
|
||||
|
||||
// TestJurisdictions_AnUndatedOperatorListingLosesToTheDatedDefault — a listing
|
||||
// that cannot decide must not be the listing in force.
|
||||
//
|
||||
// THE SILENT DISARM this closes. The operator's listing used to win on MEMBERSHIP
|
||||
// alone: a non-empty `action`/`monitoring` put it in force whatever else it said.
|
||||
// But [reference.Jurisdictions] refuses to answer from an UNDATED listing — rightly,
|
||||
// since "not listed" from a listing of unknown currency is not a fact — so a stated
|
||||
// listing missing `as_of` went in force and then errored on every single country.
|
||||
// [onGeography] swallows that error, so the ACTION tier became unreachable, the
|
||||
// freeze the rule exists for silently vanished, and what remained — review at or
|
||||
// past the freeze value — looks exactly like a rule that is working. Nothing logged
|
||||
// it, because the error that mattered was consumed per-country.
|
||||
//
|
||||
// Mutation proof: restore `if j := ...; len(j.Action) > 0 || len(j.Monitoring) > 0
|
||||
// { return j }` and the freeze assertion below fails while the Gap assertion
|
||||
// reports nothing at all.
|
||||
func TestJurisdictions_AnUndatedOperatorListingLosesToTheDatedDefault(t *testing.T) {
|
||||
// The misconfiguration, exactly: real membership, no date.
|
||||
undated := reference.Jurisdictions{
|
||||
Action: []string{"XA", "XB"},
|
||||
Monitoring: []string{"XC"},
|
||||
}
|
||||
|
||||
// FIRST, why it is a misconfiguration at all: taken whole, it can assess nothing
|
||||
// — not even a country it lists.
|
||||
if _, err := undated.Jurisdiction("XA"); err == nil {
|
||||
t.Fatal("an undated listing answered a country — the fixture does not reproduce the defect")
|
||||
}
|
||||
|
||||
got := resolve(undated)
|
||||
if got.Operator {
|
||||
t.Error("an undated operator listing is in force — it can decide nothing, so it disarms " +
|
||||
"the geography half of the rule while looking configured")
|
||||
}
|
||||
if got.Gap == "" {
|
||||
t.Error("the fallback is SILENT — an operator whose listing was discarded has no way to " +
|
||||
"learn it, which is what made this undetectable")
|
||||
}
|
||||
if !strings.Contains(got.Gap, "as_of") {
|
||||
t.Errorf("the reason does not name the field that is missing: %q", got.Gap)
|
||||
}
|
||||
|
||||
// AND THE FREEZE IS STILL REACHABLE, which is the whole point of falling back
|
||||
// rather than merely refusing the bad listing: the default is DATED, so the
|
||||
// ACTION tier answers and [onGeography]'s freeze branch can be taken.
|
||||
tier, err := got.Jurisdiction("AF")
|
||||
if err != nil {
|
||||
t.Fatalf("the listing in force cannot assess a country: %v — the freeze is still gone", err)
|
||||
}
|
||||
if tier != tierAction {
|
||||
t.Fatalf("AF is in tier %q, want %q — the tier the freeze branch turns on", tier, tierAction)
|
||||
}
|
||||
|
||||
// End to end, through the rule itself: this process states no AML_JURISDICTIONS,
|
||||
// so [jurisdictions] resolved to the same dated default the fallback selects, and
|
||||
// a large payment from that tier FREEZES.
|
||||
if d := determine("AF", freezeNano, reading{}); d.Action != cloud.ActionRestrict {
|
||||
t.Fatalf("determine(AF, freeze) = %q, want %q — the listing the misconfiguration falls "+
|
||||
"back to does not actually freeze anything", d.Action, cloud.ActionRestrict)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJurisdictions_TheStatedListingWinsWhenItCanDecide is the other half, so the
|
||||
// test above is a rule about USABILITY and not a rule that quietly ignores
|
||||
// operators.
|
||||
func TestJurisdictions_TheStatedListingWinsWhenItCanDecide(t *testing.T) {
|
||||
stated := reference.Jurisdictions{
|
||||
AsOf: time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC),
|
||||
Action: []string{"XA"},
|
||||
Monitoring: []string{"XC"},
|
||||
}
|
||||
got := resolve(stated)
|
||||
if !got.Operator {
|
||||
t.Fatal("a dated operator listing did not take force — the operator's listing wins whole")
|
||||
}
|
||||
if got.Gap != "" {
|
||||
t.Errorf("a usable listing reported a gap: %q", got.Gap)
|
||||
}
|
||||
// WHOLE, never merged: the default's own members are not in it. A merged listing
|
||||
// is one nobody stated and nobody can reproduce.
|
||||
if tier, err := got.Jurisdiction("AF"); err != nil || tier != "" {
|
||||
t.Errorf("AF resolved to %q (err %v) under a listing that does not name it — the "+
|
||||
"operator's listing was merged with the default", tier, err)
|
||||
}
|
||||
if tier, err := got.Jurisdiction("XA"); err != nil || tier != tierAction {
|
||||
t.Errorf("XA resolved to %q (err %v), want %q", tier, err, tierAction)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealth_ReportsTheListingItDecidesAgainst: the listing in force is READABLE
|
||||
// FROM OUTSIDE the process.
|
||||
//
|
||||
// A listing has a currency, and one that has gone stale — or one an operator
|
||||
// stated that cannot decide at all — degrades the geography half of the rule with
|
||||
// nothing anywhere saying so. That is the same argument `evicted` and `strained`
|
||||
// are on this probe for: a control that switches itself off must be visible from
|
||||
// outside, and "visible" means a reader can ask.
|
||||
//
|
||||
// It is reported, never fatal. A stale listing still decides, the default is always
|
||||
// dated, and a probe that failed on a listing's age would take the whole model
|
||||
// plane down over a reference table.
|
||||
//
|
||||
// Mutation proof: drop the listing fields from [health] and this fails while
|
||||
// TestHealthCarriesItsReport still passes.
|
||||
func TestHealth_ReportsTheListingItDecidesAgainst(t *testing.T) {
|
||||
probe.reset(true)
|
||||
app := mountApp(t)
|
||||
code, body := req(t, app, http.MethodGet, "/v1/risk/health", "", "", "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET /v1/risk/health = %d %s", code, body)
|
||||
}
|
||||
var rep map[string]any
|
||||
if err := json.Unmarshal(body, &rep); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", body, err)
|
||||
}
|
||||
for _, k := range []string{"listed", "listed_days", "listing"} {
|
||||
if _, ok := rep[k]; !ok {
|
||||
t.Errorf("the probe's body has no %q — the listing the rule decides against is "+
|
||||
"unreadable from outside the process", k)
|
||||
}
|
||||
}
|
||||
// This process states no AML_JURISDICTIONS, so the compiled default is in force
|
||||
// and the probe says so by NAME rather than leaving a reader to infer it.
|
||||
if rep["listing"] != "default" {
|
||||
t.Errorf("listing = %v, want %q", rep["listing"], "default")
|
||||
}
|
||||
// The DATE is the fact that matters: an undated listing can assess nothing, so a
|
||||
// probe reporting a listing without one would be reporting the very state that
|
||||
// silently disarms the rule.
|
||||
listed, ok := rep["listed"].(string)
|
||||
if !ok || listed == "" {
|
||||
t.Fatalf("listed = %v — the listing in force has no date, so its currency cannot be assessed",
|
||||
rep["listed"])
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, listed); err != nil {
|
||||
t.Errorf("listed %q is not RFC 3339: %v", listed, err)
|
||||
}
|
||||
if _, reported := rep["listing_gap"]; reported {
|
||||
t.Errorf("the probe reports a listing gap with none stated: %v", rep["listing_gap"])
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,13 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/aml/pkg/anomaly"
|
||||
"github.com/luxfi/aml/pkg/reference"
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
@@ -176,6 +179,195 @@ func (p *plane) regimeNow(t tenant) (int, error) {
|
||||
return r.pol, nil
|
||||
}
|
||||
|
||||
// ── the determination's stated bounds ────────────────────────────────────────
|
||||
//
|
||||
// A rule over stated facts needs stated bounds, and they are policy in exactly
|
||||
// the sense the rest of this file is: numbers and listings an operator has to be
|
||||
// able to read, defend and change, rather than literals buried in the rule that
|
||||
// applies them. [determine] reads them and holds none of its own.
|
||||
//
|
||||
// THEY ARE THE DEPLOYMENT'S, NOT THE ORGANISATION'S, and the reason is the row
|
||||
// above. A version is FIXED WIDTH ([maxPolicyRowBytes]) — three numbers, a flag
|
||||
// and two bounded identifiers — and that is precisely what makes a version count
|
||||
// a byte bound. A per-organisation list of countries is the variable-length value
|
||||
// that bound forbids, so stating these per organisation is a second record with
|
||||
// its own budget and its own retention: a later cut, never a field smuggled onto
|
||||
// this one.
|
||||
|
||||
// nanoPerUSD converts the wire's unit to a figure a person states thresholds in.
|
||||
// A nano is 10^-9 USD, which is the unit [riskEvent.Nano] carries and the one
|
||||
// [riskEvent.observation] divides by to reach the model's dimensionless ratio.
|
||||
const nanoPerUSD = 1_000_000_000
|
||||
|
||||
// freezeNano is the value at or above which a top-up from a jurisdiction the
|
||||
// listing CALLS FOR ACTION on is frozen rather than merely examined.
|
||||
//
|
||||
// Ten thousand USD. It is the figure supervisors build their own reporting
|
||||
// obligations around, and it is far past what self-serve credit at a card door is
|
||||
// for — the door exists so a customer can buy inference, not move money.
|
||||
const freezeNano = 10_000 * nanoPerUSD
|
||||
|
||||
// reviewNano is the value at or above which a top-up is examined WHATEVER the
|
||||
// jurisdiction, including one carrying no risk signal at all.
|
||||
//
|
||||
// Fifty thousand USD, and it is deliberately a review and never a freeze. Review
|
||||
// PROCEEDS ([cloud.RiskVerdict.Allowed]) — it summons a person and serves the
|
||||
// customer — so setting it where a large legitimate top-up lands costs a look
|
||||
// rather than a refusal. An amount on its own is also not a determination that
|
||||
// anything is wrong: it is a reason to look, which is exactly what review means.
|
||||
const reviewNano = 50_000 * nanoPerUSD
|
||||
|
||||
// burstEvents is how many events on ONE of an event's identifiers — its subject,
|
||||
// its counterparty pair or its device — inside the aggregates' narrowest window
|
||||
// make a BURST ([onPace]).
|
||||
//
|
||||
// Sixty, which is one a minute for an hour on ONE identifier. Two bounds meet at
|
||||
// that figure and both are held by
|
||||
// [TestDetermine_TheAggregateBoundsCannotDisableTheRules]:
|
||||
//
|
||||
// IT MUST BE ABOVE WHAT A FOLD ALONE PRODUCES. A tenant's own feature surface
|
||||
// folds into the aggregates one observation per (subject, [featureBucket]),
|
||||
// which is twelve an hour for a continuously active subject. A bound at or
|
||||
// under that is not a burst detector — it is a detector of having been active
|
||||
// all hour, firing on this organisation's most ordinary customers, and it
|
||||
// would fire on its OWN history the moment a residency rebuilt.
|
||||
//
|
||||
// IT MUST BE ABOVE ZERO. A count bound at zero is not a permissive setting; it
|
||||
// is the rule firing on every event there is, which is the same control being
|
||||
// useless in the other direction.
|
||||
//
|
||||
// There is no matching bound for the VALUE a burst accrues, and deliberately not:
|
||||
// [reviewNano] and [freezeNano] are that bound already, read over a window instead
|
||||
// of over one event. What one payment may not move, an hour of payments may not
|
||||
// move either — one statement of appetite, two readings.
|
||||
const burstEvents = 60
|
||||
|
||||
// fanSubjects is how many DISTINCT subjects sharing ONE device or ONE
|
||||
// counterparty make a network of nominally unrelated persons rather than a
|
||||
// household, an office or a popular merchant ([onFan]).
|
||||
//
|
||||
// Twenty. It is deliberately generous, because the finding it supports is a
|
||||
// REVIEW and never a freeze: twenty accounts on one device fingerprint is well
|
||||
// past a family and well past a shared laptop, and the response to it is a person
|
||||
// looking rather than a payment stopping. A tighter bound would summon that person
|
||||
// for every office.
|
||||
//
|
||||
// IT MUST BE ABOVE ONE, and this is the direction that would fail silently in the
|
||||
// other sense: one distinct subject is EVERY device, so a bound of one — or of
|
||||
// zero, which the count is also the query's LIMIT for — turns "shared" into
|
||||
// "exists" and the rule into noise. It must also stay under [recordRows], or it
|
||||
// names a number the tenant's own retained record can never reach and the rule is
|
||||
// switched off with nothing to see.
|
||||
const fanSubjects = 20
|
||||
|
||||
// listedAsOf dates [defaultJurisdictions]. A listing with no date cannot have its
|
||||
// currency assessed, and [reference.Jurisdictions] refuses one that has none —
|
||||
// correctly, because "not listed" from an undated listing is not a fact.
|
||||
var listedAsOf = time.Date(2026, time.June, 27, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// defaultJurisdictions is the higher-risk country listing in force when the
|
||||
// operator has stated none, and it is a RISK tier — never a sanctions
|
||||
// determination.
|
||||
//
|
||||
// THE DISTINCTION IS THE WHOLE POINT. A formal designation (OFAC, UN, EU, OFSI)
|
||||
// is a legal finding about a named party, it is made by the screening engine that
|
||||
// holds the designations, and nothing in this app makes one. What this is, is the
|
||||
// tier a jurisdiction sits in for the purpose of pricing RISK at a credit door —
|
||||
// which is a judgement an operator is entitled to make and must be able to state.
|
||||
// The two lists stay separate because the required response differs:
|
||||
//
|
||||
// ACTION countermeasures are called for. This tier may freeze.
|
||||
// MONITORING increased monitoring. This tier may examine, and no further.
|
||||
//
|
||||
// Collapsing them into one "risky" flag loses exactly the distinction the rule
|
||||
// needs in order to choose between the two, which is why [reference.Jurisdictions]
|
||||
// keeps them apart and why this does too.
|
||||
//
|
||||
// WHY A COMPILED DEFAULT EXISTS AT ALL, given that the listing it defaults to is
|
||||
// stated by an operator and changes several times a year: without one, an
|
||||
// unconfigured deployment answers "not listed" for every country on earth, and the
|
||||
// geography half of the rule is silently inert at the one door it was built for.
|
||||
// A control that switches itself off without saying so is worse than no control.
|
||||
// So the default is stated here, dated, and it LOSES to anything the operator
|
||||
// states — and because the rule ships in shadow, a stale entry cannot refuse
|
||||
// anybody until an organisation is deliberately armed.
|
||||
var defaultJurisdictions = reference.Jurisdictions{
|
||||
AsOf: listedAsOf,
|
||||
// Jurisdictions with no functioning anti-money-laundering supervision to
|
||||
// assess a payer against, or subject to comprehensive restrictions.
|
||||
Action: []string{"AF", "CU", "IR", "KP", "MM", "SY"},
|
||||
// Jurisdictions under increased monitoring.
|
||||
Monitoring: []string{"HT", "LY", "SS", "VE", "YE"},
|
||||
}
|
||||
|
||||
// listing is the jurisdiction listing in force, and the account of how it was
|
||||
// resolved. The two travel together because "which listing decided this" is part
|
||||
// of what makes a freeze defensible, and because the ONE state an operator cannot
|
||||
// see for themselves — a listing they stated that cannot decide anything — has to
|
||||
// be reportable rather than merely survivable.
|
||||
type listing struct {
|
||||
reference.Jurisdictions
|
||||
// Operator is whether this is the OPERATOR's listing rather than the compiled
|
||||
// default.
|
||||
Operator bool
|
||||
// Gap is why a stated operator listing was NOT taken. Empty when none was
|
||||
// stated, or when the one stated is in force.
|
||||
Gap string
|
||||
}
|
||||
|
||||
// jurisdictions is the listing the rule evaluates against: the OPERATOR's if they
|
||||
// stated a usable one, and [defaultJurisdictions] otherwise.
|
||||
//
|
||||
// The operator's wins whole rather than merging, because a merged listing is one
|
||||
// nobody stated and nobody can reproduce. It is resolved once per process —
|
||||
// membership is not something a request may move.
|
||||
//
|
||||
// A STATED LISTING WITH NO DATE IS NOT A LISTING, and this is the correction.
|
||||
// [reference.Jurisdictions] refuses to answer from an undated one — rightly, since
|
||||
// "not listed" from a listing of unknown currency is not a fact — so preferring one
|
||||
// whole on the strength of its MEMBERSHIP alone put a listing in force that then
|
||||
// errored on every country. Every determination fell to the unplaced branch, the
|
||||
// ACTION tier became unreachable, and the freeze the rule exists for vanished:
|
||||
// what remained was review at or past the freeze value, which looks exactly like a
|
||||
// rule that is working. The operator sees no error, because the one that matters is
|
||||
// swallowed per-country by design.
|
||||
//
|
||||
// So the date is part of what makes an operator listing USABLE, it is checked
|
||||
// where the listing is chosen, and an unusable one loses to the dated compiled
|
||||
// default instead of disarming the half of the rule it was stated to arm. The
|
||||
// reason is carried out on [listing.Gap] rather than logged from in here: this is
|
||||
// resolved once, lazily, and a control that switched itself off must be visible
|
||||
// from OUTSIDE the process — [Mount] says it at startup and /v1/risk/health keeps
|
||||
// saying it.
|
||||
//
|
||||
// UNSET and MALFORMED are left exactly as they were. [reference.JurisdictionsFromEnv]
|
||||
// answers the empty listing for both, the empty listing falls to the default here,
|
||||
// and the default is dated — so neither ever reaches the rule as a listing that
|
||||
// cannot decide.
|
||||
var jurisdictions = sync.OnceValue(func() listing { return resolve(reference.JurisdictionsFromEnv()) })
|
||||
|
||||
// resolve chooses the listing in force from whatever the operator stated. It is
|
||||
// separated from the memoization above because they are two things: reading the
|
||||
// environment happens once per process, and CHOOSING is a rule — one that has to be
|
||||
// exercised against every shape an operator can stated, which a value resolved once
|
||||
// at first use cannot be.
|
||||
func resolve(stated reference.Jurisdictions) listing {
|
||||
switch {
|
||||
case len(stated.Action) == 0 && len(stated.Monitoring) == 0:
|
||||
// Unset, or malformed JSON: [reference.JurisdictionsFromEnv] answers the empty
|
||||
// listing for both, and both correctly take the dated default.
|
||||
return listing{Jurisdictions: defaultJurisdictions}
|
||||
case stated.AsOf.IsZero():
|
||||
return listing{
|
||||
Jurisdictions: defaultJurisdictions,
|
||||
Gap: "AML_JURISDICTIONS states " + strconv.Itoa(len(stated.Action)+len(stated.Monitoring)) +
|
||||
" countries with no `as_of` date, so no country can be assessed against it; " +
|
||||
"the compiled default listing of " + listedAsOf.Format("2006-01-02") + " is in force",
|
||||
}
|
||||
}
|
||||
return listing{Jurisdictions: stated, Operator: true}
|
||||
}
|
||||
|
||||
// policyWindow is the rolling window the rate bound is measured over.
|
||||
const policyWindow = 24 * time.Hour
|
||||
|
||||
|
||||
@@ -74,7 +74,8 @@ func TestPolicy_AWriteAnswersThePolicyAndNotTheModel(t *testing.T) {
|
||||
probe.reset(true)
|
||||
app := mountBilled(t, &ledger{available: 1_000_000})
|
||||
|
||||
code, body := req(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA,
|
||||
// An ARMING write, so the caller is an admin of its own org ([admitArming]).
|
||||
code, body := reqAdmin(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA,
|
||||
`{"review":0.02,"sample":0.10,"live":true}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("PUT /v1/risk/policy = %d %s", code, body)
|
||||
@@ -172,10 +173,82 @@ func TestPolicy_ARestatementNeedsNoFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicy_ArmingIsAnAdminActAndTuningIsNot — the authority boundary inside one
|
||||
// write.
|
||||
//
|
||||
// Arming and tuning arrive at the SAME address in the same body, and only one of
|
||||
// them is self-service. Before this, neither was gated by anything but billing
|
||||
// ([ops.gate]) and the tenant guard ([ops.admit]) — neither of which asks about
|
||||
// authority — so any member of an organisation could PUT {"live":true} and take
|
||||
// its model out of shadow: a decision that lets the model freeze that
|
||||
// organisation's customers' payments, taken by whoever happened to hold a token.
|
||||
//
|
||||
// ORG-ADMIN AND NOT SUPERADMIN is the whole shape of it. The organisation is
|
||||
// arming ITSELF, so requiring platform sudo would make every customer's governance
|
||||
// decision Hanzo's to take. [cloud.Admin] is that scope, and SuperAdmin is its
|
||||
// stated superset rather than the requirement.
|
||||
//
|
||||
// Mutation proof: delete the [admitArming] call in [ops.appetite] and the first
|
||||
// case answers 200; make it require [cloud.Super] and the second fails; make it
|
||||
// gate on anything but `live` and the third fails.
|
||||
func TestPolicy_ArmingIsAnAdminActAndTuningIsNot(t *testing.T) {
|
||||
probe.reset(true)
|
||||
app := mountBilled(t, &ledger{available: 1_000_000})
|
||||
|
||||
// A MEMBER MAY NOT ARM.
|
||||
code, body := req(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA,
|
||||
`{"review":0.02,"sample":0.10,"live":true}`)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("a plain member armed the org: %d %s, want 403 — taking a model live decides "+
|
||||
"whether it may freeze this organisation's customers' payments", code, body)
|
||||
}
|
||||
|
||||
// AND NOTHING MOVED. A refusal that still recorded the regime would be the
|
||||
// arming arrived at through the door that refused it.
|
||||
if held := readPolicy(t, app, orgA); held.Version != 0 || len(held.History) != 0 {
|
||||
t.Fatalf("the refused arming still minted version %d over %d versions",
|
||||
held.Version, len(held.History))
|
||||
}
|
||||
|
||||
// TUNING IS SELF-SERVICE. The appetite and the sample are the organisation's own
|
||||
// operating point and any member may state them; gating them would make a
|
||||
// governance rule out of a number.
|
||||
code, body = req(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA,
|
||||
`{"review":0.02,"sample":0.10}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("a member could not state the appetite: %d %s, want 200 — only ARMING is an "+
|
||||
"admin act", code, body)
|
||||
}
|
||||
tuned := readPolicy(t, app, orgA)
|
||||
if tuned.Version != 1 {
|
||||
t.Fatalf("the member's tuning landed as version %d, want 1", tuned.Version)
|
||||
}
|
||||
if tuned.History[0].Live {
|
||||
t.Error("a tuning write took the model LIVE — `live` defaults false on every call, so " +
|
||||
"arming is never a side effect of changing a number")
|
||||
}
|
||||
|
||||
// AN ADMIN OF THAT ORG MAY ARM.
|
||||
code, body = reqAdmin(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA,
|
||||
`{"review":0.02,"sample":0.10,"live":true}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("an admin of this org could not arm it: %d %s — the organisation arming ITSELF "+
|
||||
"is org-scoped and self-service, never platform sudo", code, body)
|
||||
}
|
||||
armed := readPolicy(t, app, orgA)
|
||||
if !armed.History[0].Live {
|
||||
t.Fatalf("the admin's arming did not take: %+v", armed.History[0])
|
||||
}
|
||||
}
|
||||
|
||||
// putRegime states a regime and decodes the policy value it answers.
|
||||
//
|
||||
// It calls as an org ADMIN because the regimes stated through it arm the model, and
|
||||
// arming is an admin act ([admitArming]). Tuning alone needs no admin, and
|
||||
// [TestPolicy_TuningTheAppetiteIsSelfService] is what holds that half open.
|
||||
func putRegime(t *testing.T, app *zip.App, body string) riskPolicyOut {
|
||||
t.Helper()
|
||||
code, raw := req(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA, body)
|
||||
code, raw := reqAdmin(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA, body)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("PUT /v1/risk/policy = %d %s", code, raw)
|
||||
}
|
||||
|
||||
@@ -114,8 +114,9 @@ func TestPolicy_EveryScoreCitesTheRegimeItWasDecidedUnder(t *testing.T) {
|
||||
"fact to report, not a version to invent", got)
|
||||
}
|
||||
|
||||
// State one, and the next score cites it.
|
||||
code, body = req(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA,
|
||||
// State one, and the next score cites it. It ARMS, so the caller is an admin of
|
||||
// its own org ([admitArming]).
|
||||
code, body = reqAdmin(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA,
|
||||
`{"review":0.02,"sample":0.10,"live":true}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("appetite = %d %s", code, body)
|
||||
@@ -130,7 +131,7 @@ func TestPolicy_EveryScoreCitesTheRegimeItWasDecidedUnder(t *testing.T) {
|
||||
|
||||
// Restate it DIFFERENTLY, and the citation moves with it — which is the whole
|
||||
// point: the earlier decision above is still attributable to version 1.
|
||||
code, body = req(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA,
|
||||
code, body = reqAdmin(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA,
|
||||
`{"review":0.05,"sample":0.20,"live":true}`)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("second appetite = %d %s", code, body)
|
||||
@@ -229,7 +230,7 @@ func TestPolicy_HistoryIsPerTenantOverTheWire(t *testing.T) {
|
||||
`{"review":0.02,"sample":0.10,"live":true}`,
|
||||
`{"review":0.04,"sample":0.20,"live":true}`,
|
||||
} {
|
||||
if code, body := req(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA, spec); code != http.StatusOK {
|
||||
if code, body := reqAdmin(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA, spec); code != http.StatusOK {
|
||||
t.Fatalf("orgA appetite = %d %s", code, body)
|
||||
}
|
||||
}
|
||||
@@ -668,8 +669,11 @@ func TestPolicy_AnAppetiteOutsideTheContractIsRefusedAndChangesNothing(t *testin
|
||||
probe.reset(true)
|
||||
app := mountBilled(t, &ledger{available: 1_000_000})
|
||||
|
||||
// A regime the contract admits, so there is something in force to protect.
|
||||
if code, body := req(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA,
|
||||
// A regime the contract admits, so there is something in force to protect. The
|
||||
// caller is an org admin throughout, so every refusal below is the CONTRACT's
|
||||
// and never [admitArming]'s — a bounds test that passed because the caller
|
||||
// lacked authority would be a test that cannot fail for its own reason.
|
||||
if code, body := reqAdmin(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA,
|
||||
`{"review":0.02,"sample":0.10,"live":true}`); code != http.StatusOK {
|
||||
t.Fatalf("the admissible regime was refused %d %s — the test would prove nothing", code, body)
|
||||
}
|
||||
@@ -689,7 +693,7 @@ func TestPolicy_AnAppetiteOutsideTheContractIsRefusedAndChangesNothing(t *testin
|
||||
{"a negative sample rate", `{"review":0.02,"sample":-0.1,"live":true}`},
|
||||
{"a sample rate above one", `{"review":0.02,"sample":1.5,"live":true}`},
|
||||
} {
|
||||
code, body := req(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA, bad.body)
|
||||
code, body := reqAdmin(t, app, http.MethodPut, "/v1/risk/policy", orgA, "u_"+orgA, bad.body)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("%s: PUT %s answered %d %s, want 400 — the published contract is enforced by "+
|
||||
"nothing", bad.what, bad.body, code, body)
|
||||
|
||||
@@ -58,6 +58,11 @@ type riskAppetiteIn struct {
|
||||
// Live turns the model out of shadow. It defaults to FALSE on every call, so
|
||||
// going live is always an explicit act and never a side effect of changing a
|
||||
// number.
|
||||
//
|
||||
// Setting it requires an ADMIN of this organisation. Arming decides whether the
|
||||
// model may change an outcome at all — a payment frozen, a grant refused — for
|
||||
// every customer this organisation has, which is a governance act rather than a
|
||||
// tuning one. Stating the appetite and the sample needs no admin.
|
||||
Live bool `json:"live"`
|
||||
}
|
||||
|
||||
@@ -158,6 +163,12 @@ func (o ops) policy(ctx context.Context, _ *riskPolicyIn) (*riskPolicyOut, error
|
||||
// cannot be written down is refused rather than answered from state the next
|
||||
// rollout would silently undo.
|
||||
//
|
||||
// ARMING IS AN ADMIN ACT AND TUNING IS NOT. Setting `live` requires an admin of
|
||||
// this organisation; stating the appetite and the sample is self-service for any
|
||||
// member. Taking the model live decides whether it may change an OUTCOME at all —
|
||||
// a payment frozen, a grant refused — for every customer this organisation has,
|
||||
// and that is a decision an organisation takes rather than one of its members.
|
||||
//
|
||||
// A RESTATEMENT OF THE REGIME IN FORCE MINTS NOTHING and answers the version
|
||||
// already in force. Compare the version you receive with the version you had:
|
||||
// unchanged means the numbers were the same, which is why there is no flag for it.
|
||||
@@ -182,6 +193,14 @@ func (o ops) appetite(ctx context.Context, in *riskAppetiteIn) (*riskPolicyOut,
|
||||
return nil, err
|
||||
}
|
||||
defer leave()
|
||||
// AFTER the principal is resolved, and before anything is written. Authority is
|
||||
// a question about the CALLER, so it cannot be asked until there is one; and
|
||||
// asking it before the regime's own bounds means an unauthorised caller learns
|
||||
// nothing about which appetites this op accepts. Same ordering [ops.adopt] makes,
|
||||
// for the same two reasons.
|
||||
if err := admitArming(ctx, in.Live); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ver, err := p.appetite(t, in.Review, in.Sample, in.Live, caller(ctx))
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
|
||||
@@ -44,6 +44,7 @@ package risk
|
||||
import (
|
||||
"container/list"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -543,3 +544,218 @@ func (p *plane) rebuild(t tenant) (*rings, time.Time, int, error) {
|
||||
vel.reconcile()
|
||||
return vel, edge, len(held), nil
|
||||
}
|
||||
|
||||
// ── what the aggregates already hold ─────────────────────────────────────────
|
||||
//
|
||||
// Everything above this line is how the aggregates are FILLED. This is how they
|
||||
// are READ by a rule rather than by a model, and the two readers want different
|
||||
// things: the model wants nine coordinates measured against this organisation's
|
||||
// own baselines, and a rule wants two plain counts it can compare against a
|
||||
// stated bound. Reading the model's features to recover a count would be reading
|
||||
// a ratio to recover its numerator.
|
||||
//
|
||||
// IT IS THE SUBJECT'S HISTORY AND NOT THIS EVENT. [plane.score] does not record,
|
||||
// so the event being judged is not in these numbers — which is the correct
|
||||
// reading and worth saying out loud: the bounds below are what an identifier had
|
||||
// ALREADY done when it arrived.
|
||||
|
||||
// The identifiers a determination can name, in the vocabulary an operator reads
|
||||
// rather than the aggregation-axis names the engine keys on. Both are closed
|
||||
// sets, so this is a translation and never an open string.
|
||||
const (
|
||||
// axisSubject is the payer's own account, namespaced by its kind.
|
||||
axisSubject = "subject"
|
||||
// axisPair is the payer and one counterparty together.
|
||||
axisPair = "counterparty pair"
|
||||
// axisDevice is the device fingerprint.
|
||||
axisDevice = "device"
|
||||
// axisPeer is the counterparty alone. It is NOT an aggregation axis — velocity
|
||||
// keys a counterparty only in the pair — so it appears on the fan-out reading
|
||||
// and nowhere else.
|
||||
axisPeer = "counterparty"
|
||||
)
|
||||
|
||||
// axisOf translates one aggregation axis into the word a determination says. An
|
||||
// axis this app does not know is carried through as itself: a new upstream axis
|
||||
// must read oddly in a cause, never silently as one of these.
|
||||
func axisOf(kind string) string {
|
||||
switch kind {
|
||||
case anomaly.AxisAccount:
|
||||
return axisSubject
|
||||
case anomaly.AxisPair:
|
||||
return axisPair
|
||||
case anomaly.AxisDevice:
|
||||
return axisDevice
|
||||
}
|
||||
return kind
|
||||
}
|
||||
|
||||
// paced is what the aggregates hold for ONE of an event's axes over their
|
||||
// narrowest window: how many events, and how much they moved.
|
||||
type paced struct {
|
||||
// Axis is which identifier this counts, from the closed set above.
|
||||
Axis string
|
||||
// Events and Nano are the window's plain count and its accrued value, the
|
||||
// latter in the unit the stated bounds are written in.
|
||||
Events int
|
||||
Nano int64
|
||||
// Span is the window they were read over, carried so a test can hold the rule
|
||||
// to the window it claims rather than to whichever one it happened to get.
|
||||
Span time.Duration
|
||||
}
|
||||
|
||||
// shared is how many DISTINCT subjects one of an event's LINK identifiers is
|
||||
// already tied to.
|
||||
type shared struct {
|
||||
// Axis is which identifier is shared, from the closed set above.
|
||||
Axis string
|
||||
// Subjects is how many distinct subjects the tenant's own record ties to it,
|
||||
// counted no further than the stated bound: the rule asks whether the bound is
|
||||
// reached and a larger number would answer a question nobody asked at a cost
|
||||
// nobody bounded.
|
||||
Subjects int
|
||||
}
|
||||
|
||||
// reading is what ONE tenant's own aggregates already hold about the identifiers
|
||||
// on ONE event, taken at the moment that event is judged.
|
||||
//
|
||||
// The ZERO VALUE is "the aggregates said nothing", which is the honest reading
|
||||
// for an event whose identifiers this organisation has never seen — and it makes
|
||||
// every rule over it silent rather than firing on a fresh subject.
|
||||
type reading struct {
|
||||
// Pace is one entry per aggregation axis the event names ([anomaly.Keys]).
|
||||
Pace []paced
|
||||
// Shared is one entry per LINK identifier the event carries — its device and
|
||||
// its counterparty. An identifier the event does not carry is absent rather
|
||||
// than counted as the empty string, for the same reason [anomaly.Keys] omits
|
||||
// it: every anonymous event in the tenant would otherwise pool into one.
|
||||
Shared []shared
|
||||
}
|
||||
|
||||
// pace reads what one key's aggregates hold over the NARROWEST window they keep,
|
||||
// and reports whether they keep one at all.
|
||||
//
|
||||
// THE WINDOW IS TAKEN AND NEVER NAMED. velocity's own documentation says why a
|
||||
// name is the wrong handle — "a caller that reads observations by name can check
|
||||
// at construction that the names it needs exist rather than silently reading zero
|
||||
// for a window nobody configured" — and a control that reads zero because a name
|
||||
// was misspelled is a control that is off with nothing to see. So the window is
|
||||
// selected by being the shortest one the store actually keeps: there is no name
|
||||
// to get wrong, and a store keeping no windows at all is REFUSED here rather than
|
||||
// answered with a zero that reads exactly like a quiet subject.
|
||||
//
|
||||
// The narrowest window is also the right one on its own terms. It is the burst
|
||||
// window — the finest resolution the aggregates offer — and a burst is what these
|
||||
// bounds are about; the wider windows answer "how much does this subject usually
|
||||
// do", which is the model's question and not this one's.
|
||||
//
|
||||
// It does not take the ring set's own lock: [velocity.Store] is documented safe
|
||||
// for concurrent use and shards its own locking, and the census that r.mu guards
|
||||
// is not read here.
|
||||
func (r *rings) pace(k velocity.Key) (velocity.Observation, bool) {
|
||||
var out velocity.Observation
|
||||
for _, o := range r.vel.Observe(k) {
|
||||
if out.Span == 0 || o.Span < out.Span {
|
||||
out = o
|
||||
}
|
||||
}
|
||||
return out, out.Span > 0
|
||||
}
|
||||
|
||||
// sharedByDevice and sharedByPeer count how many DISTINCT subjects one
|
||||
// identifier is already tied to across the tenant's own retained record.
|
||||
//
|
||||
// TWO STATEMENTS AND NOT ONE PARAMETERISED BY A COLUMN NAME. The column is the
|
||||
// only difference and it is the one thing that must never come from a value, so
|
||||
// it is spelled in the statement rather than substituted into it; every term that
|
||||
// IS caller data is bound.
|
||||
//
|
||||
// The `at` predicate is the record's own retention ([ringWindow]) and it is what
|
||||
// makes the read a bounded range scan of the tenant's own covering index
|
||||
// (observation(tenant, at, id)) rather than a scan of the file. The LIMIT is
|
||||
// [fanSubjects]: the rule asks whether the bound is REACHED, so counting past it
|
||||
// is work with no reader, and DISTINCT under a LIMIT stops as soon as it is.
|
||||
const sharedByDevice = `SELECT COUNT(*) FROM (
|
||||
SELECT DISTINCT kind, subject FROM observation
|
||||
WHERE tenant = ? AND at >= ? AND device = ? LIMIT ?)`
|
||||
|
||||
const sharedByPeer = `SELECT COUNT(*) FROM (
|
||||
SELECT DISTINCT kind, subject FROM observation
|
||||
WHERE tenant = ? AND at >= ? AND peer = ? LIMIT ?)`
|
||||
|
||||
// sharing counts the distinct subjects one identifier is tied to, no further than
|
||||
// the stated bound.
|
||||
func (p *plane) sharing(t tenant, statement, value string) (int, error) {
|
||||
sh, err := p.for_(t)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var n int
|
||||
if err := sh.db.QueryRow(statement, string(t),
|
||||
p.now().UTC().Add(-ringWindow).Unix(), value, fanSubjects).Scan(&n); err != nil {
|
||||
return 0, fmt.Errorf("risk: count the subjects sharing an identifier: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// prior reads what this tenant's own aggregates already hold about one event's
|
||||
// identifiers, and it is the ONE door to that reading: the axes come from
|
||||
// [anomaly.Keys], which is the same definition the ingest path records to and the
|
||||
// model reads, so a rule can never be evaluated on a key the aggregates were
|
||||
// never filled on.
|
||||
//
|
||||
// IT REFUSES RATHER THAN READING ZERO. Every failure here — an unreadable shelf,
|
||||
// a ring set keeping no window — is a state in which the aggregate rules cannot
|
||||
// decide, and an empty reading would make them SILENTLY allow. So it is an error,
|
||||
// answered by the same fail policy as any other failure of this op, which is the
|
||||
// caller's to apply. It is also a state the resident's own construction makes
|
||||
// unreachable: [newRings] always keeps velocity's standard windows and the shelf
|
||||
// is already open by the time a residency exists.
|
||||
func (p *plane) prior(t tenant, o observation) (reading, error) {
|
||||
r, err := p.resident(t)
|
||||
if err != nil {
|
||||
return reading{}, err
|
||||
}
|
||||
var out reading
|
||||
for _, k := range anomaly.Keys(o.tx(t)) {
|
||||
w, kept := r.vel.pace(k)
|
||||
if !kept {
|
||||
return reading{}, fmt.Errorf("risk: this organisation's aggregates keep no window, so no bound over them can be read")
|
||||
}
|
||||
out.Pace = append(out.Pace, paced{
|
||||
Axis: axisOf(k.Kind), Events: w.Count, Nano: nanoOfUSD(w.Sum), Span: w.Span,
|
||||
})
|
||||
}
|
||||
for _, link := range []struct{ axis, statement, value string }{
|
||||
{axisDevice, sharedByDevice, o.device},
|
||||
{axisPeer, sharedByPeer, o.peer},
|
||||
} {
|
||||
if link.value == "" {
|
||||
continue
|
||||
}
|
||||
n, err := p.sharing(t, link.statement, link.value)
|
||||
if err != nil {
|
||||
return reading{}, err
|
||||
}
|
||||
out.Shared = append(out.Shared, shared{Axis: link.axis, Subjects: n})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// nanoOfUSD converts a value the aggregates carry in USD into the unit the stated
|
||||
// bounds are written in.
|
||||
//
|
||||
// It SATURATES rather than wrapping. A sum past the int64 nano ceiling is about
|
||||
// nine billion dollars, which no real accrual reaches — but a conversion that
|
||||
// wrapped would turn the largest accrual there is into a small or negative one,
|
||||
// and the rule would read the worst event it will ever see as unremarkable.
|
||||
// Saturating is the direction that can only make a bound fire, never disable it.
|
||||
func nanoOfUSD(usd float64) int64 {
|
||||
switch {
|
||||
case !(usd > 0): // also catches NaN, which is neither > nor <= 0
|
||||
return 0
|
||||
case usd >= float64(math.MaxInt64)/nanoPerUSD:
|
||||
return math.MaxInt64
|
||||
}
|
||||
return int64(usd * nanoPerUSD)
|
||||
}
|
||||
|
||||
+274
-1
@@ -22,6 +22,7 @@ package risk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -33,6 +34,8 @@ import (
|
||||
"github.com/luxfi/aml/pkg/anomaly"
|
||||
"github.com/luxfi/aml/pkg/types"
|
||||
"github.com/luxfi/aml/pkg/velocity"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
)
|
||||
|
||||
// TestPlane_HoldsNoSharedTenantState is the STRUCTURAL half: the plane may hold
|
||||
@@ -466,7 +469,7 @@ func TestRecord_IsBoundedPerTenant(t *testing.T) {
|
||||
}
|
||||
|
||||
// recorded is how many observations a tenant's own record holds.
|
||||
func recorded(t *testing.T, p *plane, k tenant) int {
|
||||
func recorded(t testing.TB, p *plane, k tenant) int {
|
||||
t.Helper()
|
||||
sh, err := p.for_(k)
|
||||
if err != nil {
|
||||
@@ -666,3 +669,273 @@ func TestLearn_ARetriedBatchConvergesInMemoryToo(t *testing.T) {
|
||||
t.Fatalf("the subject's 24h count is %d after a retried %d-event batch", got, len(batch))
|
||||
}
|
||||
}
|
||||
|
||||
// ── what the aggregates already hold ─────────────────────────────────────────
|
||||
//
|
||||
// Everything above holds the aggregates as the MODEL's eight dimensions. These
|
||||
// hold them as a RULE's two plain counts, which is a different reader with a
|
||||
// different failure: a model reading blind is a refusal an operator can see, and
|
||||
// a rule reading zero is a control that allowed.
|
||||
|
||||
// TestPrior_ReadsTheNarrowestWindowAndNotAWiderOne.
|
||||
//
|
||||
// The count bound is a BURST bound, so it has to be read over the finest window
|
||||
// the aggregates keep. Read over the widest one, sixty events in a month would
|
||||
// trip a bound written for sixty events in an hour, and the rule would review the
|
||||
// organisation's ordinary customers instead of its fast ones.
|
||||
//
|
||||
// Mutation proof: take the widest window in [rings.pace] instead of the narrowest
|
||||
// and this fails, because the events a fortnight back are counted.
|
||||
func TestPrior_ReadsTheNarrowestWindowAndNotAWiderOne(t *testing.T) {
|
||||
probe.reset(true)
|
||||
p := newTestPlane(t)
|
||||
holdFolds(t, p)
|
||||
k := key(t, brandA, orgA)
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Three inside the burst window, three well outside it and inside the widest.
|
||||
far := []observation{
|
||||
ob(t, "far-1", kindAccount, "u_1", 10, now.Add(-20*24*time.Hour)),
|
||||
ob(t, "far-2", kindAccount, "u_1", 10, now.Add(-15*24*time.Hour)),
|
||||
ob(t, "far-3", kindAccount, "u_1", 10, now.Add(-10*24*time.Hour)),
|
||||
}
|
||||
near := []observation{
|
||||
ob(t, "near-1", kindAccount, "u_1", 10, now.Add(-4*time.Minute)),
|
||||
ob(t, "near-2", kindAccount, "u_1", 10, now.Add(-3*time.Minute)),
|
||||
ob(t, "near-3", kindAccount, "u_1", 10, now.Add(-2*time.Minute)),
|
||||
}
|
||||
// Oldest first, which is the order the rings only move forward in.
|
||||
if _, err := p.learn(k, append(far, near...)...); err != nil {
|
||||
t.Fatalf("learn: %v", err)
|
||||
}
|
||||
|
||||
seen, err := p.prior(k, near[0])
|
||||
if err != nil {
|
||||
t.Fatalf("prior: %v", err)
|
||||
}
|
||||
if len(seen.Pace) == 0 {
|
||||
t.Fatal("the reading names no axis at all, so no bound over it can fire")
|
||||
}
|
||||
got := seen.Pace[0]
|
||||
if got.Axis != axisSubject {
|
||||
t.Fatalf("the first axis is %q, want %q — [anomaly.Keys] leads with the account", got.Axis, axisSubject)
|
||||
}
|
||||
if got.Events != len(near) {
|
||||
t.Errorf("the burst window counts %d of %d recent events, with %d older ones on the same "+
|
||||
"subject — the bound is written for one window and read over another",
|
||||
got.Events, len(near), len(far))
|
||||
}
|
||||
// And it says which window it was read over, so the bound and the reading
|
||||
// cannot be about two different spans.
|
||||
if got.Span != time.Hour {
|
||||
t.Errorf("the narrowest window is %s, and every bound in [onPace] is stated for the burst "+
|
||||
"window — a change here changes what those numbers mean", got.Span)
|
||||
}
|
||||
if want := nanoOfUSD(float64(10 * len(near))); got.Nano != want {
|
||||
t.Errorf("the burst window accrued %d nano, want %d", got.Nano, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrior_CountsTheDistinctSubjectsSharingAnIdentifier.
|
||||
//
|
||||
// The fan-out is a count of SUBJECTS and never of events. Counted without
|
||||
// DISTINCT it is a second and worse velocity rule: one busy account reaches the
|
||||
// bound on its own device, and every ordinary customer is a farm.
|
||||
//
|
||||
// Mutation proof: drop DISTINCT from [sharedByDevice] and the busy subject alone
|
||||
// reaches the bound; drop the LIMIT and the count runs past it.
|
||||
func TestPrior_CountsTheDistinctSubjectsSharingAnIdentifier(t *testing.T) {
|
||||
probe.reset(true)
|
||||
p := newTestPlane(t)
|
||||
holdFolds(t, p)
|
||||
k := key(t, brandA, orgA)
|
||||
at := time.Now().UTC().Add(-time.Hour)
|
||||
|
||||
// ONE subject, many events, one device — a busy customer and not a farm.
|
||||
busy := make([]observation, 0, fanSubjects*2)
|
||||
for i := 0; i < fanSubjects*2; i++ {
|
||||
busy = append(busy, ob(t, "busy_"+itoa(i), kindAccount, "u_busy", 1,
|
||||
at.Add(time.Duration(i)*time.Second), "", "d_one"))
|
||||
}
|
||||
if _, err := p.learn(k, busy...); err != nil {
|
||||
t.Fatalf("learn: %v", err)
|
||||
}
|
||||
seen, err := p.prior(k, busy[0])
|
||||
if err != nil {
|
||||
t.Fatalf("prior: %v", err)
|
||||
}
|
||||
if len(seen.Shared) != 1 || seen.Shared[0].Axis != axisDevice {
|
||||
t.Fatalf("the reading's links are %+v, want the one device the event carries", seen.Shared)
|
||||
}
|
||||
if seen.Shared[0].Subjects != 1 {
|
||||
t.Fatalf("%d events from ONE subject on one device read as %d subjects — the fan-out counts "+
|
||||
"subjects, and a busy customer is not a network", len(busy), seen.Shared[0].Subjects)
|
||||
}
|
||||
if d := onFan(seen); d.fired() {
|
||||
t.Errorf("a busy customer's own device was found shared: %+v", d)
|
||||
}
|
||||
|
||||
// And now a real one: distinct subjects, one event apiece, the same device.
|
||||
farm := make([]observation, 0, 2*fanSubjects)
|
||||
for i := 0; i < 2*fanSubjects; i++ {
|
||||
farm = append(farm, ob(t, "farm_"+itoa(i), kindAccount, "u_farm_"+itoa(i), 1,
|
||||
at.Add(time.Duration(i)*time.Second), "", "d_farm"))
|
||||
}
|
||||
if _, err := p.learn(k, farm...); err != nil {
|
||||
t.Fatalf("learn: %v", err)
|
||||
}
|
||||
seen, err = p.prior(k, farm[0])
|
||||
if err != nil {
|
||||
t.Fatalf("prior: %v", err)
|
||||
}
|
||||
// AT THE BOUND AND NOT PAST IT. Twice as many subjects share the device, and
|
||||
// the rule asks only whether the bound was reached: counting further is work
|
||||
// with no reader, and the LIMIT is what stops it.
|
||||
if seen.Shared[0].Subjects != fanSubjects {
|
||||
t.Fatalf("%d distinct subjects on one device read as %d, want the bound %d",
|
||||
len(farm), seen.Shared[0].Subjects, fanSubjects)
|
||||
}
|
||||
if d := onFan(seen); !d.fired() {
|
||||
t.Errorf("%d distinct subjects on one device determined nothing", len(farm))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrior_ReadsNoIdentifierTheEventDoesNotCarry.
|
||||
//
|
||||
// An absent device is ABSENT and never the empty string. Counted as one, every
|
||||
// anonymous event in the organisation pools into a single identifier that reaches
|
||||
// any bound immediately — a rule that reviews the whole product because of the
|
||||
// events that named nothing. It is [anomaly.Keys]' own rule, applied to the link
|
||||
// identifiers it does not key.
|
||||
//
|
||||
// Mutation proof: drop the empty check in [plane.prior] and this fails.
|
||||
func TestPrior_ReadsNoIdentifierTheEventDoesNotCarry(t *testing.T) {
|
||||
probe.reset(true)
|
||||
p := newTestPlane(t)
|
||||
holdFolds(t, p)
|
||||
k := key(t, brandA, orgA)
|
||||
at := time.Now().UTC().Add(-time.Hour)
|
||||
|
||||
// Many subjects, none of them naming a device or a counterparty.
|
||||
batch := make([]observation, 0, fanSubjects*2)
|
||||
for i := 0; i < fanSubjects*2; i++ {
|
||||
batch = append(batch, ob(t, "anon_"+itoa(i), kindAccount, "u_anon_"+itoa(i), 1,
|
||||
at.Add(time.Duration(i)*time.Second)))
|
||||
}
|
||||
if _, err := p.learn(k, batch...); err != nil {
|
||||
t.Fatalf("learn: %v", err)
|
||||
}
|
||||
seen, err := p.prior(k, batch[0])
|
||||
if err != nil {
|
||||
t.Fatalf("prior: %v", err)
|
||||
}
|
||||
if len(seen.Shared) != 0 {
|
||||
t.Fatalf("an event naming no device and no counterparty produced %+v — every anonymous "+
|
||||
"event in the organisation would pool into one identifier", seen.Shared)
|
||||
}
|
||||
if d := onFan(seen); d.fired() {
|
||||
t.Errorf("the fan-out fired on identifiers nobody stated: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrior_IsScopedToTheAskingTenant. The reading is a SECOND thing on the decide
|
||||
// path that reads a tenant's own history, so it is a second thing that could read
|
||||
// somebody else's. Both halves are held: the rings belong to one resident, and the
|
||||
// record query carries the qualified tenant as its leading predicate.
|
||||
//
|
||||
// IT RUNS THE SHARED-FILE CROSSING, and that is what makes it a test rather than a
|
||||
// tautology. Two ORGANISATIONS have two shelf FILES, so a query with no tenant
|
||||
// predicate at all still cannot cross between them — a reader who only tried that
|
||||
// pair would prove nothing about the predicate. Two BRANDS' identically named
|
||||
// organisations share ONE file ([TestRecord_TwoBrandsShareAFileAndNotARecord]),
|
||||
// and the qualified tenant is the only thing that tells their rows apart. So the
|
||||
// farm is planted under the other BRAND, in the victim's own file.
|
||||
//
|
||||
// Mutation proof: drop `tenant = ?` from [sharedByDevice] and the other brand's
|
||||
// farm is found from this brand's first event.
|
||||
func TestPrior_IsScopedToTheAskingTenant(t *testing.T) {
|
||||
probe.reset(true)
|
||||
p := newTestPlane(t)
|
||||
holdFolds(t, p)
|
||||
victim, other := key(t, brandA, orgA), key(t, brandB, orgA) // SAME org slug, two brands
|
||||
if victim.org() != other.org() {
|
||||
t.Fatal("the two keys do not share an org slug — the test is not exercising the shared file")
|
||||
}
|
||||
at := time.Now().UTC().Add(-time.Hour)
|
||||
|
||||
// One tenant runs a farm on a device, at burst speed and at real value.
|
||||
loud := make([]observation, 0, burstEvents)
|
||||
for i := 0; i < burstEvents; i++ {
|
||||
loud = append(loud, ob(t, "loud_"+itoa(i), kindAccount, "u_loud_"+itoa(i%fanSubjects), 500,
|
||||
at.Add(time.Duration(i)*time.Second), "", "d_shared"))
|
||||
}
|
||||
if _, err := p.learn(other, loud...); err != nil {
|
||||
t.Fatalf("learn: %v", err)
|
||||
}
|
||||
// The other tenant's ONE event names the same device and the same subject
|
||||
// spelling, in the same file. Nothing about the first may be found from it.
|
||||
quiet := ob(t, "quiet-1", kindAccount, "u_loud_0", 1, at, "", "d_shared")
|
||||
if _, err := p.learn(victim, quiet); err != nil {
|
||||
t.Fatalf("learn: %v", err)
|
||||
}
|
||||
seen, err := p.prior(victim, quiet)
|
||||
if err != nil {
|
||||
t.Fatalf("prior: %v", err)
|
||||
}
|
||||
for _, w := range seen.Pace {
|
||||
if w.Events > 1 {
|
||||
t.Errorf("axis %q reads %d events for a tenant that sent one — another tenant's "+
|
||||
"traffic is in its aggregates", w.Axis, w.Events)
|
||||
}
|
||||
}
|
||||
for _, s := range seen.Shared {
|
||||
if s.Subjects > 1 {
|
||||
t.Errorf("%q reads %d subjects for a tenant that has one — another tenant's record "+
|
||||
"answered its query", s.Axis, s.Subjects)
|
||||
}
|
||||
}
|
||||
if d := determine("US", 1, seen); d.fired() {
|
||||
t.Errorf("a tenant that sent ONE small event was determined %+v on another tenant's history", d)
|
||||
}
|
||||
// And the crossing is REAL for the tenant that owns it: the same query against
|
||||
// the other key finds the farm. Without this the assertions above could pass on
|
||||
// a query that finds nothing for anybody.
|
||||
loudSeen, err := p.prior(other, loud[0])
|
||||
if err != nil {
|
||||
t.Fatalf("prior(other): %v", err)
|
||||
}
|
||||
if d := onFan(loudSeen); !d.fired() {
|
||||
t.Fatalf("the farm is not found by the tenant that ran it (%+v) — the assertions above "+
|
||||
"prove nothing", loudSeen.Shared)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNanoOfUSD_SaturatesRatherThanWrapping. The aggregates accrue in float USD
|
||||
// and every stated bound is written in int64 nano, so this conversion is on the
|
||||
// path of every pace determination. One that wrapped would turn the largest
|
||||
// accrual there is into a small — or negative — one, and the rule would read the
|
||||
// worst event it will ever see as unremarkable.
|
||||
func TestNanoOfUSD_SaturatesRatherThanWrapping(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
usd float64
|
||||
want int64
|
||||
}{
|
||||
{0, 0},
|
||||
{-1, 0}, // an aggregate cannot owe money, and zero is the honest reading
|
||||
{math.NaN(), 0}, // and NaN is neither greater nor less than zero
|
||||
{10_000, freezeNano},
|
||||
{50_000, reviewNano},
|
||||
{1e30, math.MaxInt64}, // past the ceiling: the largest bound there is
|
||||
{math.Inf(1), math.MaxInt64}, //
|
||||
} {
|
||||
if got := nanoOfUSD(tc.usd); got != tc.want {
|
||||
t.Errorf("nanoOfUSD(%v) = %d, want %d", tc.usd, got, tc.want)
|
||||
}
|
||||
}
|
||||
// The DIRECTION is what matters: a saturated value can only make a bound fire.
|
||||
d := onPace(reading{Pace: []paced{{Axis: axisSubject, Events: burstEvents, Nano: nanoOfUSD(1e30)}}})
|
||||
if d.Action != cloud.ActionRestrict {
|
||||
t.Errorf("a burst accruing past the int64 ceiling determined %q, want %q",
|
||||
d.Action, cloud.ActionRestrict)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/cloud"
|
||||
"github.com/hanzoai/cloud/openapi"
|
||||
@@ -98,6 +99,14 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
|
||||
} else {
|
||||
s.State.plane = p
|
||||
}
|
||||
// THE LISTING IS RESOLVED AT MOUNT, so a misconfiguration is announced before a
|
||||
// decision is taken rather than discovered by the first payment that needed it.
|
||||
// It is otherwise resolved lazily, on whichever request first reaches the
|
||||
// geography half — which is exactly when nobody is reading.
|
||||
if gap := jurisdictions().Gap; gap != "" {
|
||||
s.Log.Error("the stated jurisdiction listing cannot assess any country; the compiled "+
|
||||
"default is in force and the freeze it arms is NOT the one you stated", "gap", gap)
|
||||
}
|
||||
mount(s, app)
|
||||
mounted = s
|
||||
// The internal scorer, published AFTER the state is built and the surface is
|
||||
@@ -233,6 +242,26 @@ func health(s *cloud.Service[state], c *zip.Ctx) error {
|
||||
// A control that switches itself off must be visible from outside.
|
||||
held, built, evicted, strained := s.State.plane.residents()
|
||||
res["resident"], res["built"], res["evicted"], res["strained"] = held, built, evicted, strained
|
||||
// THE LISTING, DATED. The geography half of the rule can only decide against a
|
||||
// listing whose currency can be assessed, and a listing that has quietly gone
|
||||
// stale — or one an operator stated that cannot decide at all — degrades the
|
||||
// rule with nothing anywhere saying so. A date and an age are what let that be
|
||||
// read from outside, which is the same argument `evicted` and `strained` above
|
||||
// are here for.
|
||||
//
|
||||
// It is NOT a degradation. A stale listing still decides, the default is always
|
||||
// dated, and a probe that failed on a listing's age would take the model plane
|
||||
// down over a reference table. Reported, never fatal.
|
||||
j := jurisdictions()
|
||||
res["listed"] = j.AsOf.UTC().Format(time.RFC3339)
|
||||
res["listed_days"] = int(j.Age(time.Now().UTC()).Hours() / 24)
|
||||
res["listing"] = "default"
|
||||
if j.Operator {
|
||||
res["listing"] = "operator"
|
||||
}
|
||||
if j.Gap != "" {
|
||||
res["listing_gap"] = j.Gap
|
||||
}
|
||||
return c.JSON(http.StatusOK, res)
|
||||
}
|
||||
|
||||
|
||||
+29
-2
@@ -100,7 +100,8 @@ func planeDecide(ctx context.Context, in *contract.RiskDecideIn) (*contract.Risk
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obs, err := decideEvent(in).observation(time.Now())
|
||||
ev := decideEvent(in)
|
||||
obs, err := ev.observation(time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -116,7 +117,33 @@ func planeDecide(ctx context.Context, in *contract.RiskDecideIn) (*contract.Risk
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
return answer(d), nil
|
||||
// WHAT THE ORGANISATION'S OWN AGGREGATES ALREADY HELD about this event's
|
||||
// identifiers, read HERE and not inside the rule, because the rule is pure over
|
||||
// stated facts and this is the one place a tenant is known. It is read AFTER the
|
||||
// score and before the fusion, so both judges describe the same event; the score
|
||||
// records nothing, so the reading is what those identifiers had already done
|
||||
// when this one arrived.
|
||||
//
|
||||
// A reading that cannot be taken is an ERROR and never an empty one: empty is
|
||||
// "this subject has done nothing", which is the answer that would silently allow.
|
||||
seen, err := p.prior(t, obs)
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
// THE MODEL IS ONE OF TWO JUDGES. [determine] reads the stated facts the model
|
||||
// does not model — the jurisdiction the payer acted from, the value moving, and
|
||||
// the pace and fan-out of the identifiers carrying it — and [fuse] takes the
|
||||
// severer of the two verdicts. It runs on EVERY answer, including the refusals:
|
||||
// the whole reason a rule sits here is that a fresh account's model is warming,
|
||||
// and warming is exactly when a first large payment from a listed jurisdiction
|
||||
// arrives — or when the twentieth account on one device does.
|
||||
//
|
||||
// The value is [riskEvent.Nano] — the same number the model read, so the two
|
||||
// judges cannot disagree about the amount. The posture is the ASSESSMENT's own
|
||||
// ([anomaly.Assessment.Shadow], set on every path the engine returns by), not a
|
||||
// fresh read of the regime: it is the posture this verdict was actually reached
|
||||
// under, which is the same reason [decided] carries its policy version.
|
||||
return fuse(answer(d), determine(signal(in.Signals, contract.SignalCountry), ev.Nano, seen), d.A.Shadow), nil
|
||||
}
|
||||
|
||||
// planeTenant mints the tenant a PLANE call acts for: the org the CALLER stated,
|
||||
|
||||
@@ -1383,6 +1383,44 @@ func caller(ctx context.Context) string {
|
||||
return c.User()
|
||||
}
|
||||
|
||||
// admitArming refuses to take an organisation's model LIVE for a caller who is not
|
||||
// an admin of it.
|
||||
//
|
||||
// ARMING IS NOT TUNING, and only one of the two is self-service. Restating the
|
||||
// appetite moves how much of an organisation's own stream it examines; arming
|
||||
// decides whether the model may change an OUTCOME at all — a payment frozen, a
|
||||
// grant refused — for every customer that organisation has. That is a governance
|
||||
// act, and before this the whole regime was one write behind [ops.gate]'s billing
|
||||
// check and [ops.admit]'s tenant guard, neither of which asks anything about
|
||||
// authority: any member of an org could PUT {"live":true} and arm it.
|
||||
//
|
||||
// ORG-ADMIN, AND DELIBERATELY NOT SUPERADMIN. The organisation is arming ITSELF,
|
||||
// so its own admin is exactly the right authority and requiring platform sudo
|
||||
// would make every customer's governance decision Hanzo's to take. [cloud.Admin]
|
||||
// is that scope — the org's own admin, with SuperAdmin as the stated superset —
|
||||
// read through the platform's ONE predicate set rather than a fourth spelling of
|
||||
// it.
|
||||
//
|
||||
// DISARMING IS LEFT SELF-SERVICE, which is the scope of the finding and not an
|
||||
// oversight worth hiding: returning a model to shadow cannot freeze a payment, and
|
||||
// today no organisation is armed at all.
|
||||
//
|
||||
// Off the HTTP path there is no principal, so there is no authority and no arming
|
||||
// — the same fail-closed answer [caller] gives, for the same reason. It lives
|
||||
// beside [ops.gate] and [caller] because this package reaches for the raw request
|
||||
// in ONE file or in as many as nobody is counting.
|
||||
func admitArming(ctx context.Context, live bool) error {
|
||||
if !live {
|
||||
return nil
|
||||
}
|
||||
c, ok := cloud.Request(ctx)
|
||||
if !ok || !cloud.Admin.Admits(cloud.AuthorityOf(c)) {
|
||||
return zip.ErrForbidden("taking this organisation's model live is an act for an admin of " +
|
||||
"this organisation; stating the appetite is not")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// window validates a day count and returns it as a duration. 400 days is the
|
||||
// surface's own retention, so asking for more asks for rows that do not exist.
|
||||
func window(days int) (time.Duration, error) {
|
||||
|
||||
@@ -265,7 +265,26 @@ func TestNoInputCarriesAnOrganisation(t *testing.T) {
|
||||
|
||||
// ── the wire ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// req calls as an ordinary MEMBER of org — the authority every tenant surface
|
||||
// admits, and the one nearly every op here needs.
|
||||
func req(t *testing.T, app *zip.App, method, path, org, user, body string) (int, []byte) {
|
||||
t.Helper()
|
||||
return call(t, app, method, path, org, user, body, false)
|
||||
}
|
||||
|
||||
// reqAdmin calls as an ADMIN OF THAT ORG: the org-scoped, self-service authority
|
||||
// the identity boundary mints as X-User-IsOrgAdmin, which [admitArming] requires to
|
||||
// take an organisation's model live.
|
||||
//
|
||||
// It is deliberately not SuperAdmin. An organisation arming its own model is its
|
||||
// own decision, and a test that reached for platform sudo to make it would be
|
||||
// asserting the wrong rule.
|
||||
func reqAdmin(t *testing.T, app *zip.App, method, path, org, user, body string) (int, []byte) {
|
||||
t.Helper()
|
||||
return call(t, app, method, path, org, user, body, true)
|
||||
}
|
||||
|
||||
func call(t *testing.T, app *zip.App, method, path, org, user, body string, orgAdmin bool) (int, []byte) {
|
||||
t.Helper()
|
||||
var r *http.Request
|
||||
if body == "" {
|
||||
@@ -280,6 +299,11 @@ func req(t *testing.T, app *zip.App, method, path, org, user, body string) (int,
|
||||
if user != "" {
|
||||
r.Header.Set("X-User-Id", user)
|
||||
}
|
||||
if orgAdmin {
|
||||
// The bit the identity boundary mints from the caller's role in its OWN org,
|
||||
// which is the one principal.IsOrgAdmin reads.
|
||||
r.Header.Set("X-User-IsOrgAdmin", "true")
|
||||
}
|
||||
resp, err := app.Test(r)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %s %s: %v", method, path, err)
|
||||
|
||||
@@ -239,9 +239,9 @@ func init() {
|
||||
},
|
||||
})
|
||||
zip.Describe("PUT /v1/risk/policy", zip.Doc{
|
||||
Description: "States the decision regime the caller organisation's model decides\nunder: how much of its own stream may be sent for examination, how much of the\nrest is sampled to measure what was missed, and whether the model may change an\noutcome at all.\n\nThe appetite is the decision a model is not permitted to make for itself: its\noutput is a probability, so how likely it is to MISS something is a matter of\npolicy that has to be stated, measured and reviewed rather than absorbed into a\nconstant. The alert threshold is derived from it as a quantile of the scores\nactually observed, which is what keeps its meaning as the distribution drifts.\n\nIt is DURABLE BEFORE IT IS IN FORCE. The regime is recorded as a new version on\nthe organisation's own shelf before anything in memory moves, so a policy that\ncannot be written down is refused rather than answered from state the next\nrollout would silently undo.\n\nA RESTATEMENT OF THE REGIME IN FORCE MINTS NOTHING and answers the version\nalready in force. Compare the version you receive with the version you had:\nunchanged means the numbers were the same, which is why there is no flag for it.\n\nLearned state survives the change. The model's identity covers its SHAPE — the\ninventory and the geometry — and not its appetite, so restating policy unlearns\nnothing. It also does not REPORT the learned state: what the model is is read\nfrom the model.",
|
||||
Description: "States the decision regime the caller organisation's model decides\nunder: how much of its own stream may be sent for examination, how much of the\nrest is sampled to measure what was missed, and whether the model may change an\noutcome at all.\n\nThe appetite is the decision a model is not permitted to make for itself: its\noutput is a probability, so how likely it is to MISS something is a matter of\npolicy that has to be stated, measured and reviewed rather than absorbed into a\nconstant. The alert threshold is derived from it as a quantile of the scores\nactually observed, which is what keeps its meaning as the distribution drifts.\n\nIt is DURABLE BEFORE IT IS IN FORCE. The regime is recorded as a new version on\nthe organisation's own shelf before anything in memory moves, so a policy that\ncannot be written down is refused rather than answered from state the next\nrollout would silently undo.\n\nARMING IS AN ADMIN ACT AND TUNING IS NOT. Setting `live` requires an admin of\nthis organisation; stating the appetite and the sample is self-service for any\nmember. Taking the model live decides whether it may change an OUTCOME at all —\na payment frozen, a grant refused — for every customer this organisation has,\nand that is a decision an organisation takes rather than one of its members.\n\nA RESTATEMENT OF THE REGIME IN FORCE MINTS NOTHING and answers the version\nalready in force. Compare the version you receive with the version you had:\nunchanged means the numbers were the same, which is why there is no flag for it.\n\nLearned state survives the change. The model's identity covers its SHAPE — the\ninventory and the geometry — and not its appetite, so restating policy unlearns\nnothing. It also does not REPORT the learned state: what the model is is read\nfrom the model.",
|
||||
Fields: map[string]string{
|
||||
"riskAppetiteIn.live": "Live turns the model out of shadow. It defaults to FALSE on every call, so\ngoing live is always an explicit act and never a side effect of changing a\nnumber.",
|
||||
"riskAppetiteIn.live": "Live turns the model out of shadow. It defaults to FALSE on every call, so\ngoing live is always an explicit act and never a side effect of changing a\nnumber.\n\nSetting it requires an ADMIN of this organisation. Arming decides whether the\nmodel may change an outcome at all — a payment frozen, a grant refused — for\nevery customer this organisation has, which is a governance act rather than a\ntuning one. Stating the appetite and the sample needs no admin.",
|
||||
"riskAppetiteIn.review": "Review is the share of the stream that may be sent for examination, in\n(0, 0.5]. The alert threshold is derived from it as a quantile of the scores\nactually observed, so the level is governed rather than tuned.",
|
||||
"riskAppetiteIn.sample": "Sample is the share of below-the-line events retained for review, in\n[0, 1]. It is the instrument that measures what the model missed; there are\nno labels, so nothing else can.",
|
||||
"riskPolicyOut.changes": "Changes is how many DISTINCT regimes may be adopted per Window. A restatement\nidentical to the regime in force mints no version and is not counted against\nit.",
|
||||
|
||||
+53
@@ -181,6 +181,59 @@ func clientAddr(peerAddr string, forwarded [][]byte, tp proxySet) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// CountryHeader is the name our edge states the caller's jurisdiction under: the
|
||||
// ISO 3166-1 alpha-2 code it resolved from the connecting address. One name, so
|
||||
// an operator has one thing to set at the edge and one thing to STRIP from
|
||||
// inbound requests.
|
||||
const CountryHeader = "CF-IPCountry"
|
||||
|
||||
// ClientCountry is the jurisdiction our edge resolved the caller from, or "" when
|
||||
// nothing trustworthy said.
|
||||
//
|
||||
// IT IS THE SAME TRUST RULE AS [ClientIP], applied to a header instead of a
|
||||
// chain, and it is written beside it so the two cannot drift into two rules. A
|
||||
// header is a claim; what makes a claim readable is WHO the socket peer is:
|
||||
//
|
||||
// a direct caller's header is the CLIENT's own writing. It is refused outright
|
||||
// — reading it would let any caller state its own jurisdiction, which on a rule
|
||||
// that escalates on geography is the same as switching the rule off.
|
||||
//
|
||||
// a trusted peer's header is our EDGE's, written after the edge resolved the
|
||||
// address it saw. That is the only version of this fact anybody here holds.
|
||||
//
|
||||
// WHAT IT IS NOT. It is the country of the ADDRESS, never of the payer: an
|
||||
// address is what a VPN moves and a proxy relays, so this is a weak signal by
|
||||
// construction and is documented as one at its one consumer. The strong signal —
|
||||
// the billing or KYC jurisdiction of the account — is not derivable in this binary
|
||||
// today (there is no billing address, no KYC profile, and the card never touches
|
||||
// this process), and inventing one from this would be worse than stating that.
|
||||
//
|
||||
// The value is normalised to upper case and refused unless it is exactly two
|
||||
// letters. Cloudflare states "XX" for an address it could not place and "T1" for
|
||||
// Tor, and neither is a country; both fail the letters test and come back "",
|
||||
// which is the same answer as silence and the correct one — no jurisdiction was
|
||||
// established.
|
||||
func ClientCountry(c *zip.Ctx) string {
|
||||
return clientCountry(c.Fiber().IP(), string(c.Fiber().Request().Header.Peek(CountryHeader)), trustedProxies())
|
||||
}
|
||||
|
||||
// clientCountry IS the rule, as a pure function of the three facts it turns on —
|
||||
// the socket peer, what the header said, and which addresses are ours — for the
|
||||
// same reason [clientAddr] is one: it can be read and tested without a server.
|
||||
func clientCountry(peerAddr, stated string, tp proxySet) string {
|
||||
peer, ok := parseClientAddr(peerAddr)
|
||||
if !ok || !tp.has(peer) {
|
||||
// No peer we can place, or a direct caller. Either way the header is not
|
||||
// our edge's and is therefore not evidence.
|
||||
return ""
|
||||
}
|
||||
code := strings.ToUpper(strings.TrimSpace(stated))
|
||||
if len(code) != 2 || code[0] < 'A' || code[0] > 'Z' || code[1] < 'A' || code[1] > 'Z' {
|
||||
return ""
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// parseClientAddr parses one chain entry or peer address into a canonical
|
||||
// address. It accepts a bare address and an address:port pair, and it UNMAPS
|
||||
// IPv4-in-IPv6 so "::ffff:1.2.3.4" and "1.2.3.4" are one key rather than two.
|
||||
|
||||
@@ -192,3 +192,85 @@ func TestTrustedProxy(t *testing.T) {
|
||||
t.Fatal("a non-address is not a proxy")
|
||||
}
|
||||
}
|
||||
|
||||
// The country rule is the SAME rule as the address one, applied to a header, and
|
||||
// the attack it stops is the mirror image: a caller able to state its own
|
||||
// jurisdiction could state one the risk plane does not act on, which on a rule
|
||||
// that escalates on geography is the same as switching the rule off.
|
||||
func TestClientCountry(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
peer, stated string
|
||||
want string
|
||||
}{{
|
||||
name: "our own edge stated it, so it is evidence",
|
||||
peer: "10.0.0.5", stated: "AF", want: "AF",
|
||||
}, {
|
||||
name: "a DIRECT caller's header is the caller's own writing",
|
||||
// The whole rule. 203.0.113.9 is not one of ours, so nothing it says about
|
||||
// where it is counts for anything.
|
||||
peer: "203.0.113.9", stated: "US", want: "",
|
||||
}, {
|
||||
name: "a direct caller cannot state a listed jurisdiction either",
|
||||
// The inverse direction: the header is ignored whatever it says, so a
|
||||
// caller cannot forge somebody else into a freeze.
|
||||
peer: "203.0.113.9", stated: "AF", want: "",
|
||||
}, {
|
||||
name: "case and padding are normalised",
|
||||
peer: "10.0.0.5", stated: " af ", want: "AF",
|
||||
}, {
|
||||
name: "a non-country code is refused by shape",
|
||||
// T1 is what an edge states for Tor. It is not a jurisdiction.
|
||||
peer: "10.0.0.5", stated: "T1", want: "",
|
||||
}, {
|
||||
name: "silence stays silence",
|
||||
peer: "10.0.0.5", stated: "", want: "",
|
||||
}, {
|
||||
name: "a country name is not a country code",
|
||||
peer: "10.0.0.5", stated: "Afghanistan", want: "",
|
||||
}, {
|
||||
name: "an unparseable peer trusts nothing",
|
||||
peer: "not-an-address", stated: "AF", want: "",
|
||||
}}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := clientCountry(tc.peer, tc.stated, ourProxies); got != tc.want {
|
||||
t.Errorf("clientCountry(%q, %q) = %q, want %q", tc.peer, tc.stated, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// And over a real request, so the header NAME and the peer plumbing are exercised
|
||||
// rather than only the rule beneath them.
|
||||
func TestClientCountry_OverARealRequest(t *testing.T) {
|
||||
var got string
|
||||
app := zip.New(zip.Config{})
|
||||
app.Get("/probe", func(c *zip.Ctx) error {
|
||||
got = ClientCountry(c)
|
||||
return c.JSON(http.StatusOK, map[string]string{"ok": "1"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
req.Header.Set(CountryHeader, "af")
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An in-memory test connection reports the unspecified address, which the
|
||||
// default set trusts as one of ours — so the header IS read here, which is
|
||||
// what this exercises: the header NAME and the normalisation, end to end.
|
||||
// The direct-caller refusal is the table above.
|
||||
if got != "AF" {
|
||||
t.Fatalf("ClientCountry over a request = %q, want %q", got, "AF")
|
||||
}
|
||||
|
||||
// No header is no country, which is a different fact from a country nobody
|
||||
// listed and must stay distinguishable from one.
|
||||
req = httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
if _, err := app.Test(req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("a request stating no jurisdiction produced %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package cloud
|
||||
|
||||
// coresidence_test.go — the scorer seam's composition argument, as a GATE.
|
||||
//
|
||||
// risk.go states it in prose: this is an IN-PROCESS handoff, and which apps share
|
||||
// a process is a per-plugin CHOICE rather than a law. Two of those choices are
|
||||
// currently load-bearing for the fleet's safety, and both are one import away from
|
||||
// being reversed by somebody who has not read that file:
|
||||
//
|
||||
// apps/commerce INSTALLS a scorer (installRiskScorer, on the credit door's own
|
||||
// plane client). It is the seam's first and only producer.
|
||||
// apps/gateway ARMS the fleet-wide abuse gate on cloud.RiskScorerInstalled(),
|
||||
// and refuses mode=live while that reads false.
|
||||
//
|
||||
// As composed today plugin/commerce links commerce alone and plugin/gateway links
|
||||
// gateway alone, so the gateway process sees no scorer and the abuse gate cannot
|
||||
// be armed. That refusal is the fail-SAFE direction and it is currently
|
||||
// UNCONDITIONAL — which is exactly what makes it fragile: nothing anywhere fails
|
||||
// when it stops being true.
|
||||
//
|
||||
// Link the two into one root and RiskScorerInstalled() starts answering true in
|
||||
// the process that arms the gate. The gate would then be armed on the strength of
|
||||
// a CO-RESIDENCY ACCIDENT rather than on the question arming actually asks —
|
||||
// whether the risk plane can answer for the FLEET, which is a cross-process ask no
|
||||
// in-process global can settle (gateway.go says so at the check itself). Arming a
|
||||
// fail-CLOSED privileged gate across the whole API plane on a wrong answer is not
|
||||
// a defect that shows up in a test of either app.
|
||||
//
|
||||
// So the invariant is structural, and it is checked structurally: no composition
|
||||
// root may LINK both. Imports are followed transitively, because linking is
|
||||
// transitive — a root that reaches commerce through three intermediate packages
|
||||
// has linked it just as surely as one that names it.
|
||||
|
||||
import (
|
||||
"go/build"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// modulePath is this module, and therefore the prefix that separates a
|
||||
// FIRST-PARTY import from a dependency.
|
||||
//
|
||||
// Only first-party edges are followed, and that is complete rather than a
|
||||
// shortcut: apps/commerce and apps/gateway are packages of this module, and no
|
||||
// package outside it can import back into it. A path into either therefore lies
|
||||
// entirely inside this module.
|
||||
const modulePath = "github.com/hanzoai/cloud/"
|
||||
|
||||
// the two apps whose co-residence the seam's safety currently rests on.
|
||||
const (
|
||||
scorerApp = "github.com/hanzoai/cloud/apps/commerce"
|
||||
gateApp = "github.com/hanzoai/cloud/apps/gateway"
|
||||
)
|
||||
|
||||
func TestComposition_NoRootLinksBothTheScorerAndTheAbuseGate(t *testing.T) {
|
||||
roots := compositionRoots(t)
|
||||
if len(roots) < 2 {
|
||||
t.Fatalf("found %d composition roots — the scan is not looking at the fleet, so its "+
|
||||
"silence proves nothing", len(roots))
|
||||
}
|
||||
|
||||
// Counted INDEPENDENTLY of the violation below, so that a root which links both
|
||||
// still proves the scan can see each of them. Folding the two questions together
|
||||
// would make a real violation also report the scan as blind, which is the
|
||||
// opposite of what happened.
|
||||
var scorerRoots, gateRoots int
|
||||
for _, root := range roots {
|
||||
linked := links(t, root)
|
||||
hasScorer, hasGate := linked[scorerApp], linked[gateApp]
|
||||
if hasScorer {
|
||||
scorerRoots++
|
||||
}
|
||||
if hasGate {
|
||||
gateRoots++
|
||||
}
|
||||
if hasScorer && hasGate {
|
||||
t.Errorf("%s links BOTH %s and %s.\n"+
|
||||
"\tcommerce installs the risk scorer and gateway arms its fleet-wide abuse gate on "+
|
||||
"cloud.RiskScorerInstalled(). In one process that predicate answers true, and the "+
|
||||
"gate becomes armable on a co-residency accident rather than on whether the risk "+
|
||||
"plane can answer for the FLEET — which is a cross-process question no in-process "+
|
||||
"global settles. Arm it deliberately (a plane op, as the obs event door does), "+
|
||||
"never by linking.", root, scorerApp, gateApp)
|
||||
}
|
||||
}
|
||||
|
||||
// ANTI-VACUITY. A renamed app, a moved root directory or a typo in either
|
||||
// constant above would make the assertion pass by reaching nothing at all, and a
|
||||
// gate that cannot fail is not a gate.
|
||||
if scorerRoots == 0 {
|
||||
t.Errorf("no composition root links %s — the scan cannot see the scorer's installer, "+
|
||||
"so it is not policing anything", scorerApp)
|
||||
}
|
||||
if gateRoots == 0 {
|
||||
t.Errorf("no composition root links %s — the scan cannot see the abuse gate, "+
|
||||
"so it is not policing anything", gateApp)
|
||||
}
|
||||
}
|
||||
|
||||
// compositionRoots is every `package main` under the two trees that hold them:
|
||||
// plugin/<app> is an app's own root, cmd/<binary> is a program's.
|
||||
func compositionRoots(t *testing.T) []string {
|
||||
t.Helper()
|
||||
var roots []string
|
||||
for _, tree := range []string{"plugin", "cmd"} {
|
||||
err := filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || !d.IsDir() {
|
||||
return err
|
||||
}
|
||||
// A directory with no Go files, or one that will not resolve, is simply not
|
||||
// a root — never a reason to fail the invariant.
|
||||
if pkg, err := build.ImportDir(path, 0); err == nil && pkg.Name == "main" {
|
||||
roots = append(roots, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", tree, err)
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// links is every first-party package a root reaches, transitively.
|
||||
func links(t *testing.T, root string) map[string]bool {
|
||||
t.Helper()
|
||||
seen := map[string]bool{}
|
||||
var walk func(dir string)
|
||||
walk = func(dir string) {
|
||||
pkg, err := build.ImportDir(dir, 0)
|
||||
if err != nil {
|
||||
// Unresolvable or Go-free: it contributes no edges. The anti-vacuity
|
||||
// assertions above are what catch a scan that resolves too little.
|
||||
return
|
||||
}
|
||||
// Imports only — never TestImports. A test's dependencies are not linked into
|
||||
// the binary, and the invariant is about what the binary holds.
|
||||
for _, imp := range pkg.Imports {
|
||||
if !strings.HasPrefix(imp, modulePath) {
|
||||
continue
|
||||
}
|
||||
if seen[imp] {
|
||||
continue
|
||||
}
|
||||
seen[imp] = true
|
||||
walk(strings.TrimPrefix(imp, modulePath))
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return seen
|
||||
}
|
||||
+14
-5
@@ -850,11 +850,10 @@ const (
|
||||
)
|
||||
|
||||
// The signal names the scorer READS. Every other name a gate observes still
|
||||
// travels and is still reported with the decision; these four are the ones that
|
||||
// name a coordinate of the model's own event, so they are spelled in the package
|
||||
// both halves import rather than agreed by convention — a gate and a scorer that
|
||||
// spell "nano" differently do not fail, they quietly score every payment as
|
||||
// moving no money.
|
||||
// travels and is still reported with the decision; these are the ones the scorer
|
||||
// acts on, so they are spelled in the package both halves import rather than
|
||||
// agreed by convention — a gate and a scorer that spell "nano" differently do not
|
||||
// fail, they quietly score every payment as moving no money.
|
||||
const (
|
||||
// SignalNano is the value moved, in nano-USD. Absent means the event moves no
|
||||
// money and the value features read BLIND, which is a different fact from zero.
|
||||
@@ -866,6 +865,16 @@ const (
|
||||
SignalDevice = "device"
|
||||
// SignalAt is when it happened, RFC 3339. Absent means now.
|
||||
SignalAt = "at"
|
||||
// SignalCountry is the jurisdiction the payer acted from, ISO 3166-1 alpha-2.
|
||||
//
|
||||
// It is the one name here that is NOT a coordinate of the model's own event.
|
||||
// The model learns one organisation's own behaviour and a country is not a
|
||||
// dimension of that; this is read by the DETERMINISTIC rule beside the model,
|
||||
// which judges stated facts rather than learned mass. It is spelled here for
|
||||
// the same reason as the rest — one spelling, both halves — and a gate that
|
||||
// cannot state it omits it, which is a different fact from stating that the
|
||||
// payer is somewhere unremarkable.
|
||||
SignalCountry = "country"
|
||||
)
|
||||
|
||||
// RiskDecideIn is one question for the scorer: what is being judged, at which
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"put": {
|
||||
"operationId": "riskSetPolicy",
|
||||
"summary": "State the decision regime: the appetite, the sample, and whether the model is live",
|
||||
"description": "States the decision regime the caller organisation's model decides\nunder: how much of its own stream may be sent for examination, how much of the\nrest is sampled to measure what was missed, and whether the model may change an\noutcome at all.\n\nThe appetite is the decision a model is not permitted to make for itself: its\noutput is a probability, so how likely it is to MISS something is a matter of\npolicy that has to be stated, measured and reviewed rather than absorbed into a\nconstant. The alert threshold is derived from it as a quantile of the scores\nactually observed, which is what keeps its meaning as the distribution drifts.\n\nIt is DURABLE BEFORE IT IS IN FORCE. The regime is recorded as a new version on\nthe organisation's own shelf before anything in memory moves, so a policy that\ncannot be written down is refused rather than answered from state the next\nrollout would silently undo.\n\nA RESTATEMENT OF THE REGIME IN FORCE MINTS NOTHING and answers the version\nalready in force. Compare the version you receive with the version you had:\nunchanged means the numbers were the same, which is why there is no flag for it.\n\nLearned state survives the change. The model's identity covers its SHAPE — the\ninventory and the geometry — and not its appetite, so restating policy unlearns\nnothing. It also does not REPORT the learned state: what the model is is read\nfrom the model.",
|
||||
"description": "States the decision regime the caller organisation's model decides\nunder: how much of its own stream may be sent for examination, how much of the\nrest is sampled to measure what was missed, and whether the model may change an\noutcome at all.\n\nThe appetite is the decision a model is not permitted to make for itself: its\noutput is a probability, so how likely it is to MISS something is a matter of\npolicy that has to be stated, measured and reviewed rather than absorbed into a\nconstant. The alert threshold is derived from it as a quantile of the scores\nactually observed, which is what keeps its meaning as the distribution drifts.\n\nIt is DURABLE BEFORE IT IS IN FORCE. The regime is recorded as a new version on\nthe organisation's own shelf before anything in memory moves, so a policy that\ncannot be written down is refused rather than answered from state the next\nrollout would silently undo.\n\nARMING IS AN ADMIN ACT AND TUNING IS NOT. Setting `live` requires an admin of\nthis organisation; stating the appetite and the sample is self-service for any\nmember. Taking the model live decides whether it may change an OUTCOME at all —\na payment frozen, a grant refused — for every customer this organisation has,\nand that is a decision an organisation takes rather than one of its members.\n\nA RESTATEMENT OF THE REGIME IN FORCE MINTS NOTHING and answers the version\nalready in force. Compare the version you receive with the version you had:\nunchanged means the numbers were the same, which is why there is no flag for it.\n\nLearned state survives the change. The model's identity covers its SHAPE — the\ninventory and the geometry — and not its appetite, so restating policy unlearns\nnothing. It also does not REPORT the learned state: what the model is is read\nfrom the model.",
|
||||
"tags": [
|
||||
"risk"
|
||||
],
|
||||
@@ -378,7 +378,7 @@
|
||||
"riskAppetiteIn": {
|
||||
"properties": {
|
||||
"live": {
|
||||
"description": "Live turns the model out of shadow. It defaults to FALSE on every call, so\ngoing live is always an explicit act and never a side effect of changing a\nnumber.",
|
||||
"description": "Live turns the model out of shadow. It defaults to FALSE on every call, so\ngoing live is always an explicit act and never a side effect of changing a\nnumber.\n\nSetting it requires an ADMIN of this organisation. Arming decides whether the\nmodel may change an outcome at all — a payment frozen, a grant refused — for\nevery customer this organisation has, which is a governance act rather than a\ntuning one. Stating the appetite and the sample needs no admin.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"review": {
|
||||
|
||||
@@ -96,6 +96,35 @@ const (
|
||||
ActionBlock = "block"
|
||||
)
|
||||
|
||||
// Severity ranks the vocabulary above, and it exists so that TWO judgements about
|
||||
// one event compose into one answer: the severest of them stands.
|
||||
//
|
||||
// It is here rather than at the one gate that fuses today because the ordering IS
|
||||
// a property of the vocabulary — the constants are declared "most permissive
|
||||
// first" and this makes that sentence executable, in the same file, so the prose
|
||||
// and the code cannot drift into two orderings.
|
||||
//
|
||||
// AN UNRECOGNISED ACTION RANKS BELOW ALLOW. It is not a milder verdict; it is a
|
||||
// string this vocabulary does not contain, and letting one place anywhere in the
|
||||
// order would let a typo win a fusion and become the outcome. Ranking it lowest
|
||||
// means the judgement that IS recognised decides, and the fail policy — which is
|
||||
// [Decide]'s, not this function's — is what answers for the unrecognised one.
|
||||
func Severity(action string) int {
|
||||
switch action {
|
||||
case ActionAllow:
|
||||
return 0
|
||||
case ActionReview:
|
||||
return 1
|
||||
case ActionChallenge:
|
||||
return 2
|
||||
case ActionRestrict:
|
||||
return 3
|
||||
case ActionBlock:
|
||||
return 4
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// The reasons an answer is not a scored one. A caller that logs, audits or
|
||||
// reports an outcome reports this beside it, so "allowed" and "allowed because
|
||||
// nobody was listening" are never the same row.
|
||||
|
||||
Reference in New Issue
Block a user