planes: the statistical plane is shadow until somebody arms it

api.Deployment carried `Shadow bool`, whose zero value is false — so an
assembly that said nothing about the behavioural model got one contributing to
verdicts. cmd/amld set it correctly, which is exactly why nothing would have
caught it: the default only bites whoever writes the next Deployment.

Stated from the LIVE side, the zero value is shadow. Detection has to be
testable before it is activated — a new deployment scores, learns and publishes
at GET /v1/aml/anomaly what it WOULD have alerted on, and contributes nothing
until somebody has read that. A default that arms a statistical control for
anyone who did not think about it is the one direction a default must not fail
in.

Found in my own work in this cycle, not reported by the review.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-02 11:45:32 -07:00
co-authored by Hanzo Dev
parent ac6f204b08
commit b99d35dbed
4 changed files with 42 additions and 13 deletions
+1 -1
View File
@@ -810,7 +810,7 @@ processed.
| `AML_TOKEN_KEY` | KMS-held tokenisation root, hex, ≥32 bytes. No default |
| `AML_DEFAULT_ORG` | Label carried by the rule catalog. The catalog is the deployment's, not a tenant's |
| `AML_BUSINESS_ZONE` | Zone business-day and business-hour rules are answered in. Default UTC |
| `AML_ANOMALY` | `live` leaves shadow mode. Anything else scores without contributing |
| `AML_ANOMALY` | `live` leaves shadow mode. Anything else scores without contributing. The assembly states it from the LIVE side (`api.Deployment.Live`) so the zero value is shadow — a default that armed the statistical plane for anyone who did not think about it is the one direction a default must not fail in |
The five planes take no configuration. A list, a suppression and a rung are all
tenant DECLARATIONS carrying a reason and a decider, so none of them is a switch an
+5 -6
View File
@@ -146,12 +146,11 @@ func main() {
Zone: zone,
Rate: reference.RatesFromEnv(),
Jurisdictions: reference.JurisdictionsFromEnv(),
// Shadow until someone has read what the model would have done.
// Detection has to be testable before it is activated, so a new
// deployment scores, learns and publishes at GET /v1/aml/anomaly what
// it WOULD have alerted on, contributing nothing to any transaction's
// outcome until AML_ANOMALY=live. The rules are unaffected.
Shadow: os.Getenv("AML_ANOMALY") != "live",
// The behavioural model contributes to a verdict only once somebody
// has read what it would have done. The rules are unaffected either
// way. See api.Deployment.Live, which is stated from the live side so
// that the zero value is shadow.
Live: os.Getenv("AML_ANOMALY") == "live",
})
if err != nil {
return fmt.Errorf("refusing to start: %w", err)
+11 -6
View File
@@ -81,11 +81,16 @@ type Deployment struct {
Rate reference.Rates
// Jurisdictions is the sanctioned-jurisdiction reference the rules read.
Jurisdictions reference.Jurisdictions
// Shadow keeps the behavioural model out of every verdict while it learns and
// reports what it would have done. It is the safe default and it is true for
// the zero value, because a detection that acts before anyone has read what it
// would have done is a detection nobody chose.
Shadow bool
// Live lets the behavioural model contribute to a verdict.
//
// It is stated from the LIVE side so that the zero value is shadow. A
// detection has to be testable before it is activated: a new deployment
// scores, learns and publishes at GET /v1/aml/anomaly what it WOULD have
// alerted on, and contributes nothing to any transaction's outcome until
// somebody has read that. Written as `Shadow bool` the same field would arm
// the model for anyone who did not think about it, which is the one direction
// a default must never fail in.
Live bool
// Limit is the reporting limit transactions are judged against. Zero takes
// the fallback.
Limit float64
@@ -126,7 +131,7 @@ func Wire(app core.App, d Deployment) (*Handler, error) {
// behavioural measure reads; the model reads them to score whether a
// transaction is unusual for the entity that made it.
windows := velocity.New(velocity.Config{})
model, err := anomaly.New(anomaly.Config{Shadow: d.Shadow}, windows)
model, err := anomaly.New(anomaly.Config{Shadow: !d.Live}, windows)
if err != nil {
return nil, fmt.Errorf("the behavioural plane cannot be built: %w", err)
}
+25
View File
@@ -163,3 +163,28 @@ func TestTheDegradationReportNamesEveryBoundedStore(t *testing.T) {
t.Error("a tenant that has been scored reports no model")
}
}
// TestTheBehaviouralModelIsShadowUntilSomebodyArmsIt.
//
// A default that fails towards "the statistical plane is now deciding" is the one
// direction a default must not fail in: detection has to be testable before it is
// activated, and a model that acts before anyone has read what it would have done
// is a model nobody chose. So the field is stated from the LIVE side and the zero
// value is shadow.
func TestTheBehaviouralModelIsShadowUntilSomebodyArmsIt(t *testing.T) {
app := shelves(t)
d := deployment()
d.Live = false
h, err := Wire(app, d)
if err != nil {
t.Fatal(err)
}
if !h.Anomaly.Config().Shadow {
t.Error("a deployment that said nothing about the behavioural model got one that contributes to verdicts")
}
if armed, err := Wire(shelves(t), func() Deployment { d := deployment(); d.Live = true; return d }()); err != nil {
t.Fatal(err)
} else if armed.Anomaly.Config().Shadow {
t.Error("a deployment that armed the model still got shadow")
}
}