mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
main
30
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bae63692f0 |
the corporate name is Lux Industries Inc
Lux Partners Limited is the Isle of Man company and does not appear on a public surface. Lux Industries Inc, a Delaware C corporation, is the one these copyright lines, citations and terms name. A bare "Lux Partners" is left alone unless the line says who owns something — elsewhere it is the name of a site, not a party. |
||
|
|
b99d35dbed |
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]> |
||
|
|
1b7a28d31f |
planes: one assembly, one identity for an offer, and a bound nobody can spend twice
The defining failure of the last review was not in a plane. It was that there were TWO wirings: cmd/amld assembled a Handler by hand and the tests assembled a different one, so the record fingerprint could be a struct field the suite exercised and no column stored — every retry a permanent 409 in production while every test passed. api.Wire is now the only assembly. It creates every collection, opens every shelf, makes every join and registers what has to run on a cadence; cmd/amld supplies only what an installation answers differently, the tests call the same function, and internal/source.NoLiteral reads cmd/amld to refuse a second copy. Handler completeness is checked by reflection, because a hand-written field list would be the third copy. A retry is not a second transaction. pkg/receipt resolves the identity of an OFFER once, at the top of ingest, before anything is read or written, and returns the prior answer byte for byte — so the ledger, the aggregates, the history, the alerts and the case plane all agree on one transaction instead of the ledger holding one record while the aggregates hold three. Two different facts under one reference is 409 and never 503. Because the answer is kept AFTER the work, a process that dies in between leaves no receipt, so every plane past it also recognises the transaction for itself: history does not append a second row, alerts keep the first judgement, and a judged transaction opens no second case. A bound over a COUNT of caller-sized values is not a bound. types.MaxIdent bounds every identifier at the door, which is what turns the counts downstream into figures in bytes; dictionary.MaxName does the same for a payload key, and dictionary.Ceiling is the product a test WEIGHS a full accumulator against. The field catalog's accumulator moves into roster, which admits and never removes, so per-tenant bounds multiply by ONE number an operator can hold against a pod. engine.Evaluator now keeps no compiled rules at all. A candidate's text arrives on the wire at /v1/aml/rules/test, so a compiled-rule table there was keyed by a string a caller wrote, sized by how many distinct ones a caller sends, with no cap and no removal, in the memory every institution's ingest runs in — an OOM from one authenticated caller, which on this deployment is every control off at once. A rule's compiled form is a value with an owner: the installed library, or one replay. Nothing shared, nothing to grow. Found here, not reported. Handler.history is the one function that materialises a whole retained history, so it now REQUIRES a *topology.Grant, and a Grant comes from Budget.Admit and nowhere else. An ungated whole-history read is something a caller has to write on purpose rather than something a new door can forget. The rule replay and the activation rate fold take the same one budget the model studies take. Reaching a bound is allowed; reaching it quietly is not. GET /v1/aml/load is the one door for "is anything of mine quietly degraded": the tenant's aggregates against its own ceiling in bytes with a grade, whether it has a behavioural model at all, the field catalog's pressure, and the process's counts of institutions held and refused. It names no other institution. Closing R1-R8 from the review plus R11, the two recurring classes, and the silent degradation the load door answers. Twenty-four mutations, twenty-four killed. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
90fef65897 |
planes: a bound in bytes, and a tenant that only ever spends its own
Two shapes, both on the ingest path, both proven by measurement. A bound over a COUNT of caller-sized things is not a bound. A velocity key is not a pointer to what the caller sent: it is one ring per window, 444 buckets allocated whole on the first transaction, a measured 22.7 KB. So MaxKeys=100000 meant 2,163 MiB against a 1 GiB pod — the honest reading was that the process dies at about 46,000 keys, from one tenant, with ordinary traffic. The ceiling is now in BYTES and the key count is derived from it in one place (keyCost), so the two cannot disagree; Store.Ceiling() is an upper bound a test WEIGHS a full store against. One store shared by every tenant under one cap is a way for one institution to switch another's monitoring off. Measured: a victim's 24h window went count=1 to count=0, and a victim's model went learned=40 to learned=0, purely from two other tenants' traffic — no error, no alert, and an aggregate that reads zero is what a clean account also reads. Per-tenant state now lives in pkg/roster, which ADMITS and has no removal at all: a tenant keeps what it holds, a tenant arriving with no room is refused by name (anomaly's ReasonCrowded) and counted, and the only key a tenant can drop is its own least recently used. internal/source reads both packages to keep the shape out. Reaching a bound is allowed; reaching it quietly is not. velocity.Load grades a tenant clear/crowded/full and counts what it dropped, anomaly.State carries Crowded and Pressure counts it, and roster.Default is ONE answer to how many institutions this process holds state for, so the per-tenant bounds multiply to a figure an operator can hold against a pod. Closing R5 (cross-tenant model eviction) and R5b (the adopted reload was read once per TRANSACTION under thrash, now once per admission) from the review, plus the same two shapes in pkg/velocity, which nothing had reported. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
8f0cf7ac9f |
planes: the case plane signs with the credential too
Closing a case is the oldest governed act here and the one an obligation rests on directly (AMLR Art. 69(2)): the retained assessment is what a supervisor reads. Its decider was bound to the credential with the rest of them, and this adds what was missing — the tests that say so, and the mutation that says the tests can fail. Two properties, both already true and neither proven: the retained assessment is signed by the token's subject and not by the `by` a request body carried, and a credential that names nobody cannot close a case at all. The second refuses at the assessment, which is the record that matters, and it refuses as a REFUSAL (400) rather than as an outage — a 503 reads as "retry", and this one never clears. The console still renders a "by" field on its resolve form. It is now ignored, which is the point, but a form asking for something the API discards is a lie to the analyst filling it in: ui/ owes a one-field deletion. That repo is another track's. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
7de786224f |
planes: six ways a monitoring plane could be turned against the institution running it
Each of these is a control that worked and could be made not to, by a caller holding an ordinary token for its own tenant. Every one carries a test, and every one carries a mutation in tools/mutate.py, because a bound with no test is a bound that comes back. THE EXPENSIVE PLANE COULD TAKE THE MACHINE. A model search replays a tenant's history through up to MaxTrials detectors and a fit replays it through one; both were registered plain. Four bounds now, each doing one thing and nothing else: one study per tenant (api.gate), a deadline derived from the request's own context so a client that goes away cancels the work (api.within, 2 min), a process budget whose tokens ARE the trial workers and are taken BEFORE the history is read (topology.Budget, half the cores), and a clamp on the width one request may name (MaxWorkers). The gate is a map keyed by the tenant with nothing ever evicted from it, so one institution's study is never another's refusal; the budget is the one bound that is not per tenant, deliberately, because the CPU is shared however it is modelled and waiting for the machine is not the same as being refused because of somebody else. A rule replay is admitted the same way. Fit now records what it cost, as a run already did. THE DECIDER WAS FREE TEXT. Every plane asks a governed act for a reason and a decider so a supervisor can be told who turned a control off, and the answer was whatever the caller typed. types.Decider is a distinct type carrying `json:"-"`, so no wire format has a name for one, and pkg/api/typed.go writes the AUTHENTICATED SUBJECT onto every field of that type after the body and after the path. Identity now yields a Caller — two projections of one verified credential, the tenant that indexes every store and the subject that names a decision, both from the same signature. A credential naming nobody still authenticates, because reads and ingest are the tenant's work and not a person's, and refuses every operation that would record a decision. A TENANT COULD STOP ITS OWN PAYMENTS, AND THE RETRY DOUBLE-COUNTED. suppress.Cover runs once per activation on the ingest path and failed closed past MaxCandidates, which h.monitor turned into a 503 on every transaction. It degrades now: the answer is over what was read and says so (Cover.Partial, Activation.Unchecked), and the crowding is refused at DECLARATION instead (MaxInForce), where a refusal costs one operator request rather than every payment. The retry that followed such a 503 wrote a second activation for one firing, which counted in the streak, so a declared repetition policy fired on a repeat that never happened — a payment blocked because the client retried. An activation naming a transaction is now identified by (tenant, transaction, rule, subject), hashed so no customer value is in a key. The retained record is idempotent by the same reasoning: token.Vault.Fingerprint names the plaintext deterministically, since a seal draws a fresh nonce and sealed bytes never match; and the transaction now carries ONE clock, its own. The reception clock ingest stamped made every retry a different fact under one id, which no retry could ever clear. A genuine conflict answers 409 rather than 503, because 503 reads as "retry" and a client retrying a permanent refusal retries forever. A RUNG'S COUNT WAS THE LIMIT OF AN UNBOUNDED READ. It becomes a store LIMIT on the ingest path, so it is bounded at declaration (MaxCount, MaxWithin) and clamped again where it is used, because a bound introduced after a row was written does not reach that row. THE CATALOG STORED PAYLOAD VALUES AFTER ALL. A minimum IS a value, exactly, at any volume, and at a count of one so are the sum and the mean. Declared numbers are the transaction model's own and keep their moments; a custom number is whatever the institution put under its own key and gets the sketch and nothing else, absent rather than zero. And the vocabulary itself is now bounded PER TENANT, in the accumulator and in the rows alike: MaxKeys bounds one payload used as a bag, it does not bound a tenant sending a different key on every transaction, and that growth was in the memory every other institution's ingest runs in. Reaching the bound degrades only the tenant that reached it, refuses no payload, and publishes what it turned away. AN ADOPTED CONTROL COULD GO QUIET WITH NOBODY DECIDING IT SHOULD. The fit is durable and the adoption is durable, but what adoption DOES is install learned state into a model in memory — and this is one replica with a Recreate strategy, so a rollout empties it, and the store holds a bounded number of tenants' models, so an idle institution's can be dropped for a busy one's. Either way the tenant returns to warming, which reports no alerts, which is what a quiet institution also reports. The model now asks the model plane what this tenant last adopted when it plants a model for it, and State publishes when the model started and whether it started from an adoption, so a reset is visible instead of inferred from a silence. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
7c314d0b30 |
planes: the five surfaces a monitoring programme is operated through
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]> |
||
|
|
0faf15d14a |
cases: the record plane keeps no clock of its own
The case store evicted. Ninety days after a case was closed it was dropped, with its timeline, once the store held more than a hundred thousand of them — and both the count and the sweep were taken across every tenant at once, so one institution's volume destroyed another institution's evidence, and neither of them was told. A case is the record that an alert was considered and what was decided, kept whichever way the decision went (AMLR Art. 77(1)(b); JMLSG 6.32), for five years (Dir. (EU) 2015/849 Art. 40). This repo already has a plane that keeps that clock: pkg/retention counts in years, per tenant, refuses a date its own clock has not reached, and proves what it destroyed before it reports a count. A second clock here, counting in days, could not be reconciled with it — whichever was shorter was the real retention period, and nobody wrote that one down. So the durable shelf has no bound, and no constructor that could give it one. What eviction remains is the memory shelf's, only this package's tests build it, and it is scoped to one org. The sweep is scoped at the seam rather than at its callers: shelf.each now names the org whose cases it visits, so counting, triage and eviction are each a tenant's own by construction. That closed two more crossings of the same boundary that were not eviction at all — the stale-case sweep escalated every institution's cases on any tenant's cycle, which is a write on another institution's investigation, and least-loaded assignment counted another institution's queue into this one's analyst load. Case numbering is per tenant too: drawn from one sequence for the deployment, the gaps in one institution's file references counted another's cases. Opening a case now takes no census. The count that decided whether to evict was a full scan of every case in the store, under the store lock, on every Create — the path the replay gate calls the request that must not wait — and once the store was over the threshold with nothing old enough to drop, every Create paid two of those scans forever. pkg/cases/evict_test.go is what says so, and each of these tests was checked against the defect it describes: reinstating the bound fails the two durable tests, un-scoping the sweeps fails the four tenant tests, and restoring the census fails the ingest one. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
9b7352ae4a |
aml: auth is @hanzo/iam, and the console is configured by nothing
Two changes that are really one: stop owning things that are already owned. AUTH. Deleted ui/src/auth.ts — a local PKCE implementation with its own token storage, its own refresh, its own state handling and its own idea of what signing out means. @hanzo/iam/browser is that, for every Hanzo app: configureIam + startLogin + handleCallback + getSession + getUser + logout. There is now no auth code in this repo to review, drift, or get wrong, and when IAM's end-session behaviour improves every app gets it without a line changing here. Verified against production IAM: hanzo.id reflects https://aml.hanzo.ai on discovery, token and userinfo, so the flow works from the console's origin. CONFIGURATION. Dropped config.json and the SPA_* env with it. The console derives its API from the host it is served on — aml.hanzo.ai can only ever talk to api.hanzo.ai — and reads its brand, issuer and IAM clientId from that API's /v1/aml/config, which now carries client_id because that route already answers 'which identity does this surface have'. So there is no setting that points authenticated requests somewhere, which is the setting worth attacking; the same image is every brand's; and nothing writes the served root, which is what lets the container run read-only and unprivileged. The derivation is deliberate about what it refuses: an address literal has three labels and no parent domain, so 127.0.0.1 would have become api.0.0.1. Every host shape is asserted — brand hosts, a bare domain, localhost, IPv4, IPv6. Also here, from the same review: one replay per tenant on /v1/aml/rules/test, because an empty sample opens every retained record and a handful at once occupies the engine that still has to answer ingest; and the CI smoke now asserts the invariants that replaced the old ones — no config.json is served, and the bundle names no clientId. CSP unchanged and re-proven: 7 routes, zero violations, zero console errors, still no 'unsafe-inline'. Go build/vet/gofmt clean, 18 packages green, api race-clean. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
6024f38a0f |
alerts: the last of the record leaves memory
An alert is what a rule said about a transaction at the time it was judged. The case that cites it is durable now and so is the record it was raised against, so an alert store that empties on a rollout leaves a case naming evidence that is no longer there — and the replay that measures a rule's false-positive rate reads the same alerts to know which rules actually fired. Same shape as the case plane: an app means the durable shelf, no app means the map, and the map keeps its LRU bound because memory does. The durable shelf does not evict; an alert is evidence, and the retention plane is what decides when evidence goes. One JSON column rather than a column per field. An alert is only ever read whole — the route hands back a transaction's alerts, the replay reads which rules fired — so nothing filters on a score or a citation, and a column per field would be a second place the shape of an alert is declared. TestAlertsSurviveARestart writes two tenants' alerts on one transaction, takes the bytes, opens a second instance and reads them back per tenant: the org is what scopes the read, and it still is on the other side. All three planes are now durable and each has its restart test. LLM.md carries the table. Full suite green (18 packages). Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
df3cc926ea |
cases: the timeline is readable
The events a case accumulates were written and never read: AddEvent had a
route, Events had none, and the store method had no caller. A case whose
history cannot be read back is not reviewable — the note recorded under
AMLR Art. 77(1)(b) is evidence only if somebody can see it.
GET /v1/aml/cases/{id}/events resolves the case through caseOf first, so
the timeline carries the case's own tenancy: another tenant holding the id
gets the same 404 as a case that was never opened. The store is keyed by
case id alone, which is safe only because nothing reaches it without that
check.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
e144a68a5b |
cases, jwks, docs: close Red's three low findings before the tag
Red's re-review passed (0 critical/high/medium) but left three low findings, and two are "one and only one way" violations worth closing before a version tag. LOW-2 — one way to mutate a case, org-checked. There were two ways to append a case event: an org-checked AddEventFor with no callers, and an unchecked AddEvent the handler actually used, safe only because the handler happened to resolve the tenant first. That is a cross-tenant write one refactor away. AddEvent now takes the tenant and checks it (absorbing AddEventFor, which is deleted), and Resolve and UpdateStatus do the same — there is no unchecked public path left to reach for. A test proves each refuses a caller from another tenant holding the exact case id. LOW-1 — the tenant-key invariant was false for Rule.OrgID. The types doc said every OrgID holds the brand-qualified tenant key, but the rule library is the deployment's catalog, seeded at startup from AML_DEFAULT_ORG with no Host in scope, so its OrgID is a bare org and nothing scopes on it. The invariant now names that one exception, on the field where an auditor reads it, so it is true rather than aspirational. LOW-3 — a duplicate kid in a JWKS was overwritten last-wins and silently, against this package's own "left out loudly" principle: a token the shadowed key signed would be refused with nothing logged. It is now first-wins and announced. This is unreachable from the real IAM (which dedupes by cert name) and is a guard against a malformed set. Also documented, not changed: the aud pin does not prove a token transited this app's login gate — IAM's RFC 8693 token-exchange path can stamp a chosen aud, and is gated instead by the IAM_KEY_MINT_ALLOWED_APPS allowlist. Named in iam.go as a deploy-time trust dependency rather than left as an implied guarantee. 18 packages green, race clean, gofmt clean. The case org-check is mutation-killed. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
992f784038 |
routes: the rule list and sanctions search need a tenant too
Driving the real router through every registered path — the test that caught the unregistered /v1/aml/config — also showed which routes answer a caller holding no credential at all. Two did. The rule list is the map of what this institution's monitoring looks for and what it does not, which is precisely what somebody structuring a payment around it wants; it is the deployment's catalog rather than a tenant's data, but that is an argument about scoping, not about publishing it. Sanctions search runs a fuzzy match over every designation in the set, so serving it unauthenticated hands out that work for free, and the name being searched is somebody's name. Two routes now answer without a tenant, and only two: /v1/aml/config, whose whole job is to name the issuer a token comes from, and /v1/aml/health, which a probe reads on a pod IP that names no brand. LLM.md says so, and lists the two routes it had never mentioned. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
3509e29cd7 |
iam: an unpinned deployment must also cost nothing to ask
Mutation-testing the audience guard found it unheld: removing the "no audience configured" refusal changed no test, because golang-jwt happens to refuse everything when the expected audience is the empty string (expectedAud becomes [""], which no token's aud contains). The guard was right and the test was weak — and it was resting on library behaviour nobody had written down, so a future release that read an empty expectation as "no check" would have turned every unpinned deployment into one that accepts any token, silently. The property is now stated where it is independent of the library: an instance with no audience configured refuses BEFORE resolving a keyset, so it costs no issuer round trip per request either. Breaking the guard now fails the test. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
11a44f6010 |
tenant: a tenant is a brand and an org, and the vault is keyed by both
The tenant key was the bare org, and an org name is unique within an issuer
rather than across issuers. `acme` on lux.id and `acme` on zoolabs.id are two
unrelated financial institutions, and every store keyed them as one — including
the tokenisation vault, where the org WAS the HKDF salt (pkg/token New). Two
brands therefore derived the same keys: one institution's customer names
tokenised to the other's pseudonyms, and its sealed records opened under the
other's tenant. That is a plaintext cross-brand disclosure of retained personal
data, against the purpose limitation retention exists under (Directive (EU)
2015/849 Art. 41(2)) and against HIP-0302. It is fixed now, before any record
exists, because doing it later re-keys every vault.
The tenant is now `<brand>/<org>`, minted in one place (api/tenant.go qualify)
and checked where it crosses into the store, the history column and the salt.
Both Identity implementations produce the same key: the brand comes from the
request's own Host in both, never from a header a client can write.
The auth path is now the caller that refuses rather than defaulting:
- brand.ForHost is gone, with brand.Default and brand.IssuerFor. While a
defaulting resolver existed the auth path used it, so hanzo.id
authenticated callers on every Host nobody claimed — a pod IP, a
*.svc.cluster.local name, localhost through a port-forward, an absent Host,
a misrouted vhost. brand.For reports ok, and there is no fallback to reach.
- `aud` must contain this deployment's IAM clientId (AML_CLIENT_ID, no
default, refuses to start without it). IAM stamps aud = the app's clientId,
so with no pin any first-party token from the same issuer was a credential
here, including one minted for a marketing site. RFC 9068 §4.
- `tokenType` must be "access-token". An id_token carries the same iss and
aud and is issued to a browser, not to an API caller.
- `owner` must be an org the caller is a member of, when the token carries a
membership set. IAM stamps the APP's org as `owner` (internal/oidc/jwt.go
Sign), so a shared or org-choice application would make every one of its
users one tenant. IAM refuses a cross-org login for a dedicated app
(internal/oidc/login.go), which is what makes the claim correct for the
shape this supports: one application per institution.
- a bearer that is not a compact JWS is refused before any keyset is
resolved, so an unauthenticated caller cannot spend a network round trip.
Keys, in api/jwks.go with the algorithms that accept them, because the two
lists have to agree:
- one lock per issuer, taken outside the map, so an issuer that does not
answer holds up only itself. One process-wide mutex held across the HTTP
fetch meant a single unreachable issuer — selected by a caller's Host —
serialised every request in the process behind it.
- a failed fetch is remembered briefly, so an unreachable issuer costs one
attempt per window instead of one per request.
- EC (ES256/384/512) and ML-DSA-65 verify, which is what IAM publishes
besides RSA (internal/oidc/jwks.go signingAlgs). A dropped key type is a
silent lockout of a whole brand, so a key that cannot be loaded now names
its kty and alg in the log.
Also here: caseOf returned refuse()'s value as an error, and refuse() returns
nil once it has answered the request — so an unauthenticated POST to a case
route dereferenced a nil case. It reports errNoCase and writes nothing.
Transaction.TenantID is deleted: a second tenant field nothing reads is an
invitation to scope data by a value the caller supplies.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
5690eaeb5d |
brand: an unstated issuer must refuse, not skip the check
golang-jwt validates `iss` only when an expectation is stated (validator.go, `if v.expectedIss != ""`), so a brand row carrying no issuer would not weaken the per-Host pin — it would delete it, and every brand's tokens would authenticate on that host. Stated expectation or no tenant. The shared-keyset fixture now says why it is the honest one: the live issuers serve the SAME set, and zoolabs.id publishes cert-lux beside cert-zoo, so a Lux token's signature does verify against Zoo's keys and the pin is the whole defence. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
b73c14a51a |
brand: prove the bearer scheme is compared, and name what the pin does not settle
A negative row presented the token as "Basic <jwt>", six characters where Bearer is seven, so a verifier that never compared the scheme would still have refused it — on a token it had read one character into. Digest is exactly as long, so the row now fails for the reason it claims to test. Killing that mutant is what showed the test was passing on the wrong evidence. The issuer pin stops another brand's token; it does not make org names unique across issuers, and the tenant here IS the org name. Stated where the decision lives: one record plane serving several brands has to scope by brand as well as by org, one plane per brand does not. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
e1103e15cb |
brand: the host says which brand, and the token has to come from it
One amld serves every brand's surface, so which brand a request belongs to is a property of the request. pkg/brand is the one place the engine answers that — a leaf holding the same registry as the fleet's canonical white-label table (HIP-0111, hanzoai/cloud brand/brand.go): the same brands, issuers and Host resolution. The Identity that follows from it: the tenant is the `owner` claim of a bearer token the request's OWN brand issued. A Lux token on a Zoo host is refused even where both issuers publish their signing certificates in one JWKS, which is the difference between white-label tenancy and white-label decoration. Signature against the issuer's published keys, issuer pinned, expiry required, `kid` bound to the key it names, and a refusal on every uncertainty — a control that authenticates when unsure answers with another tenant's records and looks healthy doing it. /v1/aml/config reports the brand of the request's Host so a white-label console renders its own identity. It is the one route here that takes no tenant: it names the issuer a caller needs in order to get a token at all. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
ea00e03437 |
retention: keep the ledger where a restart cannot reach it
The five-year period is the obligation, so a ledger that starts empty every time the process starts has kept nothing (AMLR Art. 77). The ledger now stands on a shelf: it holds the obligations and the shelf holds the records, in memory for exercising them and in Base collections for keeping them. pkg/store is the one way a durable store is declared here — a Kind, an idempotent Ensure that brings an existing collection up to the declaration, a tenant predicate added by the reader rather than by the caller, and field names as constants both sides of the write name. A retry is no longer a second record: a record is identified by what is unique to its class, and an assessment recurs per decision rather than per case (AMLR Art. 77(1)(b)). Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
95aef0c4f6 |
api: write the transaction to history, so aggregates can see it
History.Append had no caller. The collection existed after the previous commit and stayed empty, so every window query returned nothing and every aggregate rule evaluated to false — not to an error. That is the worse failure of the two: a rule reporting "no velocity" for a window it never read looks exactly like a customer with no velocity, and nothing anywhere says otherwise. Measured on nine deposits of 9,400 against a 10,000 limit, before and after. Before, every one of the nine returned an identical verdict — score 0.075, four alerts — because the only rules that could fire were per-transaction checks. The ninth deposit was indistinguishable from the first. After: tx 1-2 score 0.525 near-threshold only tx 3 score 1.0 structuring-day fires at three tx 5-9 score 1.0 structuring-week fires at five, seven alerts with currency-day at 0.45 on the day's aggregate of 84,600. That is the typology this product exists to detect, and it was undetectable end to end until now. The append happens before evaluation, and the order is the point: a transaction absent from the window it is judged against is judged on a history that stops one short of it, so the deposit completing a pattern is the one excluded from seeing it. A write failure refuses the transaction rather than evaluating it against an incomplete history. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
9f098d396c |
api: do not screen a customer identifier as a name
Ingest defaulted the customer's name from its identifier, so an opaque database key was screened against the designation lists. Measured against the live lists, "cust-1" scored 0.902 against "BRANCH 1 OF THE SHIRAZ REVOLUTIONARY COU" — above the 0.85 reporting threshold — and an ordinary $100 payment came back blocked. A handle cannot produce a true positive against a list of people's names, so every match it yields is false, and this one was the most expensive kind of false: the rule's action is block, so the engine declined a legitimate payment and named a sanctions hit as the reason. The customer now comes from the caller, who is the only party that knows it. With no name supplied the customer-screening rule's own guard holds and the rule does not fire, which is the honest outcome — a customer whose name this service has never been given has not been screened, and the way to screen them is to send the name rather than to invent one from a key. The catalog already publishes this as a gap. Measured end to end on a benign $100 payment, against 31,338 loaded designations: six alerts at a saturated score before this arc, then two, then blocked, and now allow / score 0 / no alerts. A designated party still matches — "Vladimir Putin" returns 28 hits, exact on OFAC, EU and OFSI. Also here: a second defect this exposed and did not fix. The matcher scoring a six-character single token at 0.902 against a six-word organisation name is wrong on its own terms, whatever is fed to it. The single-token asymmetry does not hold on this path. Left as a named finding rather than a hurried threshold change, because raising the threshold to hide it would suppress genuine transliterations, which score lower than this false positive did. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
3ae6be949e |
api: publish the detection catalog with its gaps
The catalog serves the rule set actually installed, not a document describing one, so the coverage claim is the running configuration and cannot drift from it. The published gap list is in the same response deliberately. A catalog that shows only what is covered invites the reader to assume the rest is covered too, and the reader here is a supervisor. Naming what is not detected, with the citation that requires it, is the difference between a coverage claim and a coverage assertion. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
3c041ed331 |
merge the detection library and the screening rewrite into one engine
Two branches had independently rebuilt the same two packages, which is one
implementation too many. This resolves them into one.
Their side is the base for detection and screening, because it is better:
- Rules name evidence, and SetRules refuses a set whose evidence this
deployment cannot supply — so the eleven stub helpers that silently disabled
thirteen rules cannot exist. A rule that cannot fire is worse than an absent
one, because it still appears in the catalog a regulator reads.
- One screening store, filled by the refresh and read by the endpoints. Two of
them is why sanctions search answered "no match" for every name while
designations were being loaded somewhere else.
- Parsers for all four publishers against their real files, recovering what the
single-parser path could not read at all: OFAC's digital-currency addresses,
weak aliases held apart from screening candidates, and OFSI's row-per-name
layout collapsed to subjects.
- Name matching over runes, not bytes. Two distinct Han names scored 0.867
byte-wise, above the 0.85 threshold, and 0.700 correctly.
- Deviation from the median and MAD rather than the mean, because a monitoring
baseline contains the outliers it is used to hunt.
- A rule vocabulary with providers behind every term, in place of expressions
reaching into a struct.
Kept from this side, because their branch did not have it:
- The tenant comes from an authenticated identity, not from a request header.
- A rule that could not run carries no weight. Their action() already capped a
failing rule at review, which is right, but the hit still entered the score
at full weight — so a broken rule saturated every transaction's score and the
queue stopped ranking. Faults are now excluded from the aggregate while still
raising their own alert and still forcing review.
- Per-source readiness: which list, how many designations, how long ago, and
the reason if the last attempt failed. Their store answers whether screening
can be relied on at all; this answers what is behind it, which is what an
operator can act on.
- Behavioural scoring, retention, tokenisation, replay and reference data.
Conversion now refuses an unknown currency instead of passing the amount
through. Passing it through understates every currency worth more than a dollar,
so a payment three times over a reporting limit tested as sitting under it.
One ordering table for actions (types.ActionRank), one EvalAll signature, one
screening store, one readiness surface. 15 packages, tests and race clean.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
644d8be410 |
wire the engine to one screening store, and serve the catalog
The refresh filled a Base-backed sanctions store and the search endpoint queried a separate empty in-memory one. Neither was reachable from the other, so /v1/aml/sanctions/search returned no matches for every name, permanently, and nothing in the system reported it. There is now one store, shared by the endpoints and by the engine's screening term. Screening refuses rather than reports clean. An unloaded or stale list set, a list that parsed to zero entries, or a class the deployment cannot answer all produce an error, which the engine turns into a review. Political exposure is refused outright: there is no authoritative public list of politically exposed persons and none of the four sanctions publishers is one, so answering from sanctions data would report every customer as not politically exposed. Each publisher is fetched and parsed by its own parser, and a failure for one leaves the other three loaded rather than clearing them. Transactions carry a direction and the customer's name and jurisdiction. The previous ingestion path synthesised a customer from the identifier alone, so every screening and jurisdiction rule evaluated against an empty name and an empty country and could not fire whatever the engine did. Case events check that the case belongs to the requesting organisation. A case identifier is returned to whoever submitted the transaction, so possession of it was authority to write on another tenant's investigation. Health reports function rather than process: whether the lists are ready, which loaded when, and how many terms this deployment can answer. Currency rates and the higher-risk jurisdiction listing are loaded, never compiled in. /v1/aml/catalog publishes the rules with their citations, the obligations claimed, and the obligations outstanding. Screening 31,338 real designations across the four published lists produced two false positives, both a one-word list entry matching an unrelated three-part name on a shared prefix. Raising the threshold could not separate them from genuine transliterations, which score lower; the asymmetry could. A single-token name now has to match a multi-token name near-exactly. Zero false positives after. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
eaab7eeda3 |
token: compare the mac, not the label, when checking domain separation
The test asserted that two domains mint different tokens, which the domain prefix guarantees on its own — so it passed with the per-domain key derivation removed and one key minting every domain. It now compares the mac behind the prefix, which is the thing the key schedule is responsible for. Found by mutating the HKDF info to a constant: the mutant survived, which is the only way to learn that a test passes for the wrong reason. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
9f072a8b2c |
sanctions: make screening readiness answerable per list, and show it
The defect that hid two dead endpoints for months was not that they were dead. It was that nothing could be asked whether they were alive. Screening failure is silent by construction: an empty list matches nobody, and neither does a clean party, so "no designations matched this payment" is the same answer either way. Monitor keeps the outcome of the last refresh of each list — entry count, load date, payload digest, and the reason if the last attempt failed. A failed attempt keeps the previous successful load rather than erasing it, because that data is still what screening runs against; but it ages out instead of staying fresh forever on the strength of one old success. A list that has never loaded is reported as unfit, not omitted. Ready is all-or-nothing on purpose. Screening against three of four lists is not three-quarters of a control: a party designated only on the missing list passes completely, and the result is indistinguishable from a clean one. GET /v1/aml/sanctions/sources serves this and answers 503 when any list is unfit, so a readiness probe and the dashboard read one truth instead of each deciding for itself. The dashboard panel leads with whether the instance can screen at all, above the case counts — a queue length means nothing if the lists behind it are empty — and states in words that a result against unfit lists cannot be read as clean. Six mutants killed, including the two that matter most: treating a zero-entry load as fresh, and letting three-of-four report ready. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
86dd3fb599 |
aml: check the record plane join, and put the replay report on the surface
pkg/api/records_test.go is where the three packages that do not know about each other get checked together. A transaction that cannot be recorded is refused rather than processed, and nothing is stored on the way to refusing. A blocked one is retained as a refusal running from its own five-year clock, naming the rule that refused it. A retained record reconstructs the transaction and carries no identifier in the clear. A case cannot close without its retained decision. A replay over an empty or unreadable history refuses instead of reporting zero alerts, and writes nothing either way. It is also the only place *engine.Evaluator is put behind replay.Evaluator, so the sandbox and the live path cannot drift apart without this failing to compile. cases: the closure invariant on its own — no retained assessment, no closure, no closing date. ui: the rule tester reports the replay (alerts over transactions, and the false-positive proportion when the period held judged alerts) rather than one match against a synthetic transaction. LLM.md: the record plane, what AML_TOKEN_KEY is for, the routes it added, and the open gaps — persistence first, because a retention ledger that does not survive a restart is a record-keeping breach. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
c1372cb0cc |
aml: anomaly scoring, retention, tokenisation, replay, reference data
Five gaps that a monitoring product cannot be sold without. anomaly scores whether a transaction is unusual for the entity that made it, which is the statutory question — consistency with the customer's own profile — and one no fixed threshold can express. It complements the rule library rather than replacing it: rules encode typologies someone has already named, and are blind to anything nobody has written down. The design is deliberately attributable: a score can name the features that drove it, which is what a supervisor asks for and what a neural scorer cannot answer. retention keeps records for their statutory period and destroys them when it runs out, including the retained assessment whether or not it produced a report. token protects direct identifiers at rest while keeping the engine able to correlate: one key schedule, a deterministic org-scoped pseudonym to index on and a sealed record for reads that are entitled to it, so a record can be found by party without holding the party's name in the clear. replay runs a rule over real history and reports what it would have done before anyone activates it — the same question as "how many alerts would this raise", which is what makes a rule change reviewable rather than a live experiment. reference holds the published data a rule needs but cannot derive: currency conversion and jurisdiction risk listings, each carrying the date it was current at, each refusing to answer rather than guess. Passing an unknown currency through unchanged moves every threshold by the exchange rate — the structuring rule compared raw notional, so 9,500 units of a minor currency read as a near-threshold cash amount. A jurisdiction list that answers "not listed" because it was never loaded is indistinguishable from a clean world. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
d83eec058e |
api: derive the tenant from a credential, not from a request header
Every route read its tenant from the X-Org-Id request header, and nothing authenticated the caller. A request with no credential and a header naming another organisation was served that organisation's records: knowing a transaction id was enough, and transaction ids are not secrets. The package doc asserted "All endpoints enforce X-Org-Id and require IAM auth" — the first clause described a presence check, and the second was not true anywhere. The tenant now comes from a Handler Identity, which resolves the organisation a request is authenticated to act on or fails. It is a seam rather than an implementation because this package must not become the second place that decides what a valid credential is; the deployment supplies one and every route asks it the same question. With none configured, every route refuses — a compliance product that falls back to trusting a caller-supplied tenant answers every request with somebody else's records and looks healthy doing it. TrustedProxyHeader keeps the behind-the-gateway deployment working, but as a named assumption at the construction site: it is sound only where the proxy authenticates the caller, writes the header from the verified token, strips any client copy, and is the only route in. The routes had no HTTP-level test at all, which is why this held. There are now tests that all nine tenant-scoped routes refuse without an Identity, that a forged header is refused, that the header cannot override the identity, that an empty tenant is a failure rather than a tenant, and that a caller authenticated as one organisation reading a transaction id it shares with another gets only its own alerts. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
07e69e3ee2 |
luxfi/aml — real-time AML/CFT transaction monitoring
A Go AML engine: a configurable rules DSL over expr-lang with weight-of-evidence scoring, sanctions screening against OFAC/UN/EU/HMT, case management, and scored alerting. Single binary, embedded SQLite per org, embedded UI. Surface (all /v1, org-scoped): /v1/aml/transactions ingest and score /v1/aml/rules /rules/test the rules DSL and its dry-run /v1/aml/sanctions/search list screening /v1/aml/cases investigation workflow /v1/aml/health Build: make build produces one static binary with the UI embedded (embedui tag); bare Go tooling needs no Node. Third-party license and notice texts are generated from the module graph the linker actually walks and ship beside every artifact. Co-authored-by: Hanzo Dev <[email protected]> |