Compare commits
5
Commits
lsp
...
v1.801.464
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,209 @@
|
||||
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.
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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 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 determine(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
|
||||
}
|
||||
|
||||
// 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,450 @@
|
||||
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"
|
||||
|
||||
"github.com/luxfi/aml/pkg/anomaly"
|
||||
|
||||
"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), 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), 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)
|
||||
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), 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), 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), 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)
|
||||
|
||||
// ARMING ONE TEST TENANT, and nothing else. This states a regime on this
|
||||
// test's own temporary data directory; it is not a deployment, and no
|
||||
// organisation anywhere is armed by it.
|
||||
p := mounted.State.plane
|
||||
if _, err := p.appetite(key(t, brandA, orgA), 0.02, 0.10, true /* live */, "u_"+orgA); err != nil {
|
||||
t.Fatalf("appetite: %v", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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); 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,152 @@ 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
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+15
-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,19 @@ func planeDecide(ctx context.Context, in *contract.RiskDecideIn) (*contract.Risk
|
||||
if err != nil {
|
||||
return nil, wrap(err)
|
||||
}
|
||||
return answer(d), nil
|
||||
// THE MODEL IS ONE OF TWO JUDGES. [determine] reads the stated facts the model
|
||||
// does not model — the jurisdiction the payer acted from, and the value moving
|
||||
// — 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.
|
||||
//
|
||||
// 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), 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