mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
Lists, suppressions, activations, the field catalog and the model plane. Each is its own package, each is Base-backed, and none of them can destroy anything — disposal is pkg/retention's decision alone, per tenant, and every one of them has a test that reads its own source for a call to Delete. lists holds the institution's own allow and deny lists, by class, normalised once so an address written two ways is one address. It reaches a rule as one term, Listed(name, value), and it refuses both a list nobody declared and an empty value: either would be a rule that is catalogued, reported as coverage, and incapable of firing. Removing a value stops it matching and keeps the row, because a list entry is the reason a payment was refused. suppress holds the decisions to keep a detection out of a queue. Reason and decider are required and neither is defaulted; a suppression naming neither a rule nor a subject is refused, because that is a kill switch. Lifting keeps the row. The narrowest cover wins, so a specific decision about one account is the one named on the activation rather than the rule-wide one behind it. watch writes every activation before anything happens to it, and marks the ones a suppression covered or a fold matched rather than dropping them: a system that discards what it decided not to show cannot say how much it is not showing. Action is what the rule asked for, Response is what the plane concluded, and exactly two declared mechanisms move it. A rung may only raise — lowering is a suppression by another name — and elevation beats folding, because an activation that reached an escalation rung is the escalation. The live feed is a convenience over the durable rows; it drops rather than blocking ingest, and the drops are counted per tenant and published on every poll. dictionary reports what a tenant's payloads carry: declared fields discovered by reflection over the transaction rather than from a second list, the tenant's own Raw keys, fill, blindness, and a distinct count kept as a bitmap sketch — hashed with the tenant, so two tenants' bitmaps cannot be compared, and saturating into "no longer a count" rather than drifting down. No value is ever stored. It is write-behind and says so: Pending is exactly what a restart would lose. topology searches the space of model shapes over a tenant's own history, through the detector's own learning path rather than a copy of it, and reports the learning curve, the realised alert share against the stated appetite, where each candidate went blind, and which features carried the signal. It has no store and imports nothing that has one. It names NO winner where nothing has been judged: ranking needs an outcome, and a ranking invented from unlabelled data would look like a recommendation and be a preference. Ties go to the smaller model. models keeps every search and every fitted state, because both are governed acts, and adopts a fit into the live model with its decider recorded. The learned state never leaves the store — mass counters describe where a tenant's activity is dense. A fit installs only into a model of the same shape, which is what stops a tenant's model being swapped underneath it by restoring a file. Around them: anomaly.Learn exposes the assessment Assess was already computing and throwing away, so a study of the model reads the same scores production does; engine gains one provider and one vocabulary term; and the tenant key moves to pkg/brand, where the brand registry is, so the five new planes hold the same shape at their own write boundary instead of each growing an opinion about what a tenant is. api/tenant.go's qualify and qualified are now aliases of it, not a second copy. Every operation is func(ctx, org, *In) (*Out, error): the tenant is an argument and never something the operation resolves for itself. api/typed.go is the whole of what turns one into a route — query into the input for a GET, body for a POST, the path bound last and winning — and api/planes.go is a routing table with no handler bodies in it, which a test reads as source. The contract is the Go types and nothing else, so a zip face over the same functions restates none of it. Co-authored-by: Hanzo Dev <[email protected]>
76 lines
2.6 KiB
Go
76 lines
2.6 KiB
Go
package brand
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestTenantKeyNeverRegresses holds the one property every record plane rests on:
|
|
// a tenant is `<brand>/<org>` and a bare org is not a tenant.
|
|
//
|
|
// It is here rather than in each plane because the shape is defined once. A plane
|
|
// that grew its own copy of the check would be free to disagree with this one, and
|
|
// the disagreement is a set of rows written under a key that opens another brand's
|
|
// vault.
|
|
func TestTenantKeyNeverRegresses(t *testing.T) {
|
|
key, err := Qualify("hanzo", "acme")
|
|
if err != nil {
|
|
t.Fatalf("qualify: %v", err)
|
|
}
|
|
if key != "hanzo/acme" {
|
|
t.Fatalf("tenant key = %q, want hanzo/acme", key)
|
|
}
|
|
if !Qualified(key) {
|
|
t.Fatal("a key Qualify produced must be one Qualified accepts, or the two have drifted apart")
|
|
}
|
|
|
|
// A bare org is refused everywhere a key is taken.
|
|
for _, bare := range []string{"acme", "", " ", "/acme", "acme/", "unknownbrand/acme"} {
|
|
if Qualified(bare) {
|
|
t.Errorf("%q is not a tenant key and must not be accepted as one", bare)
|
|
}
|
|
if err := Tenant(bare); !errors.Is(err, ErrTenant) {
|
|
t.Errorf("Tenant(%q) = %v, want ErrTenant", bare, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBrandQualifiesTheOrg is the sharp end: two brands' institutions of the same
|
|
// name must be two tenants.
|
|
//
|
|
// Keyed on the bare org they are one — one set of rows, and one tokenisation
|
|
// vault, because the salt is this key. That is a cross-brand plaintext disclosure
|
|
// of retained personal data, and it is what qualifying at the key makes
|
|
// uncomputable rather than merely disallowed.
|
|
func TestBrandQualifiesTheOrg(t *testing.T) {
|
|
hanzo, err := Qualify("hanzo", "acme")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zoo, err := Qualify("zoo", "acme")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hanzo == zoo {
|
|
t.Fatalf("two brands' institutions named acme resolved to one tenant key %q", hanzo)
|
|
}
|
|
if !strings.HasPrefix(hanzo, "hanzo"+Sep) || !strings.HasPrefix(zoo, "zoo"+Sep) {
|
|
t.Fatalf("a tenant key must lead with the brand that vouched for it: %q, %q", hanzo, zoo)
|
|
}
|
|
}
|
|
|
|
// TestUnreadableOrgRefused keeps the key readable back to the institution it
|
|
// names. `zoo/lux/acme` does not say which one that is.
|
|
func TestUnreadableOrgRefused(t *testing.T) {
|
|
if _, err := Qualify("zoo", "lux/acme"); err == nil {
|
|
t.Fatal("an org containing the separator makes the key ambiguous and must be refused")
|
|
}
|
|
if _, err := Qualify("nobody", "acme"); err == nil {
|
|
t.Fatal("a brand no registry claims vouches for nothing and must be refused")
|
|
}
|
|
if _, err := Qualify("hanzo", " "); err == nil {
|
|
t.Fatal("an empty org acts for no tenant and must be refused")
|
|
}
|
|
}
|