Commit Graph
36 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 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]>
2026-07-29 22:31:29 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 21:37:11 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 21:33:33 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 21:29:00 -07:00
zeekayandHanzo Dev 82427113d6 retention: one identity is one record, and the shelf is what says so
Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:57:02 -07:00
zeekayandHanzo Dev e2899843ab store: prove the exact-moment comparison, which is what a cursor pages by
Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:49:30 -07:00
zeekayandHanzo Dev cd63afac22 history: declare the transaction collection the one way collections are declared
The window query and the schema now share one declaration, and the tenant
predicate comes from the reader rather than from the filter string. Adds the
first test of the durable side of history: a window is bounded by the
subject, by the event date and by the org, and each of those fails the same
way when it is wrong — fewer events than there were.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:43:44 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 16:39:23 -07:00
zeekayandHanzo Dev 68fe46dff9 wip
Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:23:40 -07:00
zeekayandHanzo Dev 97efad3afa ci: native runners and our own registry, not GitHub's
CI and release move to .hanzo/workflows on git.hanzo.ai, and .github/workflows is
removed. The old path could not have worked: lux-private/aml has zero repository
runners, zero organisation runners, and has never run a single workflow, while
release.yml required [self-hosted, linux, amd64] and stated GitHub-hosted runners
were not a permitted fallback. A tag would have queued forever against a pool that
does not exist — a release that claims artifacts nothing built.

The native pool is live and already carries the label this needs:
git-runner-0..5 register lux-build-linux-amd64 among their labels.

Images go to oci.hanzo.ai/luxfi/aml. That is the canonical name for our own OCI
registry — registry:2, S3-backed, Hanzo IAM token auth — so both the build and the
publish run on infrastructure we operate. The luxfi/ namespace stays because the
engine is a Lux product; Hanzo AML is a thin wrapper over it, the way hanzoai/kms
wraps luxfi/kms and hanzoai/mpc wraps luxfi/mpc.

The release keeps the discipline that makes a version trustworthy: build, smoke the
image before it is allowed to exist at the registry, push, then prove the pushed
manifest resolves. A tag is a receipt for an image that exists, and verifying it
after the push is what makes it one.

The smoke checks the two behaviours that have actually regressed here rather than
liveness alone — an unauthenticated read must be refused rather than served, and an
ordinary payment must come back allowed rather than carrying a saturated score,
because a monitor that alerts on everything ranks nothing.

The /v2/ in the registry path is the OCI Distribution Specification's. Every route
this product serves is /v1/, and there are sixteen of them with no /api/ prefix.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 16:21:52 -07:00
zeekayandHanzo Dev 58bc533040 cases: inactivity is not a decision, so a timer cannot close a case
AutoClose closed low-severity cases after thirty days of inactivity, writing
Status, Resolution and ClosedAt directly. Store.Resolve refuses a closure with no
assessment — a dismissed alert is a retained decision with its rationale, not a
deleted row — and this path wrote the same fields itself and walked around that
invariant. Eviction then removed the closed case after its retention window, so an
alert that was raised, never reviewed and silently closed left no trace of having
existed. That is the record AMLR Art. 77(1)(b) requires kept whether or not it
produced a report, and the question JMLSG 6.32 requires the compliance officer to
be able to answer: why was this not escalated.

Closing asserts that somebody assessed the case and decided. Inactivity asserts
the opposite. The two must not produce the same state, so the timer now escalates
instead — an alert nobody has looked at is exactly what a compliance officer needs
to see, and escalating puts it in front of one rather than deleting the question.

It also swept only low-severity cases, which reads as caution and is the wrong way
up: a critical case nobody has touched is the more serious governance failure. Every
severity is now in scope.

Nothing on a timer can close a case, and there is a test that says so rather than a
comment. Five mutants killed, including restoring the original close.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 15:54:48 -07:00
zeekayandHanzo Dev 3f540b7d8d screen: retry a publisher that blips, so one lost packet is not a lost day
The EU list answered "tls: received record with version 302 when expecting version
303" on one request, and the same host served the same 24.8 MB file correctly
seconds later — verified afterwards across three TLS configurations, all HTTP 200.
There was no retry, and the refresh runs daily, so that single lost packet cost a
full day of screening against a list 6,017 designations short.

Transport is retried three times with a growing wait. Nothing else is: a parse
failure is a disagreement about the schema and will fail identically on the next
attempt, so retrying it delays the refresh without changing the outcome. A
cancelled context stops immediately, because that is the deployment shutting down
rather than the publisher failing.

A publisher that is genuinely down is still reported, and the error names the
attempts made — an operator who cannot tell a blip from an outage cannot decide
whether to act. One publisher failing still does not abandon the others.

The download is a parameter rather than a call into the network, which is what
makes any of this testable: five tests cover the retried blip, the persistent
outage, the un-retried parse failure, the surviving sibling and the cancellation.
Eight mutants, all killed — including removing the retry, which is the defect.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 15:42:48 -07:00
zeekayandHanzo Dev d7bc215166 sanctions: a name is a conjunction, so the weakest part decides
The matcher scored a customer key of six characters at 0.902 against a six-word
organisation name, and because that rule's action is block, an ordinary payment was
declined naming a sanctions hit as the reason. The previous commit stopped feeding
identifiers to the matcher; this fixes the matcher, which was wrong on its own
terms whatever it was fed.

Three defects, one cause: the score was a mean, and a mean lets one strong
partnership carry a weak one.

  - The guard against a fragment standing in for a whole person only covered a
    query of literally one token, and folding splits punctuation — so "cust-1"
    arrived as two tokens and walked past it. "a-1" scored a perfect 1.000 against
    "A ONE TRADING COMPANY 1", because each trivial token found an exact partner
    and the mean of two exact partnerships is exact.
  - Initials, particles and bare numbers were counted as evidence of identity.
    They match on the little they contain: every one-character token matches some
    one-character token somewhere in 31,338 designations.
  - A legal-form suffix was counted too, so "acme-ltd" was reported against an
    unrelated "…Co., Ltd" at 0.967 on the strength of Ltd matching Ltd. Every
    company has a legal form; agreeing on it is agreement about company-ness.

Now only identifying parts decide, and every one of them must match — the mean is
reported for ranking, not tested. That is what separates a variant spelling of one
person from a coincidence between two, and no threshold on the mean can do it:
"Smith Petrov" against "Smirnov Petrov" averages 0.89 on an exact surname.

partFloor is measured rather than chosen, and the measurement corrected my first
attempt. Setting it to Threshold — every part must meet the bar the whole name
must meet — reads well and refuses Yusuf against Yousef at 0.840, so a real
customer would stop matching a real designation. Across the transliteration pairs
the published lists contain, genuine variants bottom out at 0.840 and unrelated
parts top out at 0.773; 0.80 sits between the populations. Both directions are
mutation-tested.

Measured against all 31,338 live designations. Customer keys producing any match:
5 of 17 before, 4 after, and the residue is name-shaped — "wallet-1" contains
Wallet and there is a designated SMILE WALLET; "ivan-petrov-42" contains a real
Russian name. No matcher can know those are keys, which is why the input fix
matters. True positives lost: 0 of 10 — Putin, bin Laden, Kim Jong Un, al-Assad,
Kadyrov, Maduro, Khamenei, Lukashenko, Prigozhin and Sechin all still match at
1.000. Twelve mutants, all killed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 15:39:48 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 12:06:03 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 12:03:32 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 11:56:58 -07:00
zeekayandHanzo Dev 95c570b1df history: create the transaction record, so aggregate rules can reach a verdict
The collection every window query reads did not exist, and nothing created it.
No migration ran, no bootstrap existed, and the schema in migrations/0001_core.sql
was referenced from nowhere.

The effect was the whole aggregate plane. A window query against a missing
collection fails, a rule that cannot read history reaches no verdict, and twelve
of twenty rules therefore faulted on every transaction — currency reporting,
three structuring rules, pass-through layering, velocity, deviation, dormancy,
concentration and round-amount detection among them. Measured on a benign $100
payment before and after: twelve alerts, then two.

Ensure creates it and is idempotent, so it runs on every start. It lives beside
the reader rather than in a migration file because the field names are shared
between them, and a migration that drifts from them does not fail — it returns an
empty window, which reads as a customer with no history rather than as a broken
deployment.

The indexes follow the query, not the fields: every window is read as
(org, subject, time), so there is one index per subject axis. Without them each
velocity rule is a table scan per rule per transaction, which is the cost model
the sliding-window design exists to avoid.

The two remaining faults on a benign payment are the sanctions rules, which
correctly report that no list has loaded yet rather than reporting a clean screen.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 11:55:47 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 11:49:08 -07:00
zeekayandHanzo Dev c753174b0e sanctions: state precisely which OFSI paths are dead, and which is merely undocumented
The earlier note said ConList answered 404. Measured across all three paths that
is only true for two of them: publishlive/ConList.{xml,csv} are 404, but the
legacy publishlive/2022format/ConList.csv still answers 200 and still carried a
Last Updated of 03/06/2026 — months after OFSI closed the list.

That distinction matters to anyone reading this file to decide a source. A live
blob the publisher has stopped documenting is worse than a dead one, because it
can stop at any time and nothing downstream would say so.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 11:20:31 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 11:17:45 -07:00
zeekayandHanzo Dev 5736ea8168 anomaly: record the measured per-tenant cost and the design in LLM.md
The memory bound in Config was an arithmetic estimate; it is now the figure a
test measures (336 KB per tenant at the defaults, 84 MB at MaxOrgs=256), so the
number an operator sizes against and the number the code enforces are the same
one.

LLM.md gains the reasoning a later reader needs and cannot recover from the
code: why half-space trees rather than isolation forest or robust random cut
forest, the two places this implementation departs from the paper and what each
departure buys, why attributability rather than accuracy decided the choice, and
what the appetite governs. The departures matter most — averaging clamped
per-tree votes instead of summing raw masses is the difference between a score
that ranks and a score a quantile can be cut from, and a reader who assumes the
paper's aggregation would reintroduce the fault.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 11:17:01 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 11:14:53 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 11:13:09 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 11:09:43 -07:00
zeekayandHanzo Dev d8812d66f1 engine: a rule that could not run is a fault, not a match
An ordinary $100 payment came back with six alerts and a saturated score, so the
effective false-positive rate was 100% and the score ranked nothing.

The mechanism was one line of policy. A rule whose evaluation errored was
recorded as a hit with Match=true, on the reasoning that a broken rule should not
silently let a transaction through. The safety instinct is right; the encoding
conflated two different findings. "This transaction exhibits this typology" is
evidence about the transaction. "This rule did not run" is a fault in the system,
which says nothing about the transaction — and because a rule that fails once
fails on every transaction, recording it as a match alerts on all of them.

Evaluate now separates them. A matching rule produces a typology alert that
carries its score. Rules that could not be evaluated produce one alert naming
them, with no score, because a rule that did not run is not evidence of risk. The
transaction is still not cleared: the fault forces review, since a transaction
assessed by only part of the rule set has not been assessed.

Tests cover the reported case directly — six unevaluable rules against a benign
payment, expecting one fault alert, zero score, and a refusal to allow — plus a
benign transaction under a working rule set alerting not at all, and a fault
alongside a real match where only the match scores. Five mutants, including
restoring the original Match=true, all killed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 11:08:24 -07:00
zeekayandHanzo Dev 0c82c7dc4d rules: a citation-carrying library, and a published gap list
Twenty rules across twelve typologies, each naming the requirement it implements
with a locator a reviewer can open. Citations were read from the primary text.
Two figures that a coverage claim commonly gets wrong are handled explicitly:

Recommendation 16 is cited by its current title, Payment transparency. FATF
revised and renamed it in June 2025 and still hosts the pre-revised text at a
separate path; a test rejects a citation carrying the old title or that URL.

The suspicious-reporting section carries one threshold, 5,000 dollars, applying
whether or not a suspect is identified. The familiar 5,000-with-suspect and
25,000-without split belongs to the banking agencies' rules in 12 CFR, not here.
A test rejects attributing 25,000 to this section.

Currency reporting cites both the threshold section and the aggregation section,
because the first states the figure and only the second states the business-day
period. Citing only the first is how a point-in-time check comes to be described
as implementing currency reporting.

The crypto transfer rule carries no value floor, because the recast transfer
regulation applies regardless of amount; a test rejects adding one.

Gaps() publishes the requirements located in the primary text that this engine
does not meet — report workflow, filing clock, retention, tipping-off
confidentiality, correspondent banking — each with why and what would fix it. A
test rejects claiming a requirement as both covered and outstanding.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 11:07:34 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 11:04:33 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 11:03:54 -07:00
zeekayandHanzo Dev 04accf64e0 engine, history: one definition of a subject that names nobody
Subject.Valid is the single place that decides what an unusable subject is —
unknown kind, no tenant, no identifier — and the scope that builds subjects and
both stores that consume them all defer to it. Each of those failures has the
same consequence and it is the worst one available: a subject naming nobody
yields an empty window, and a rule over an empty window sees no activity, which
is indistinguishable from a customer who did nothing.

A rule that reaches no verdict now records review regardless of the action it was
configured with. A failing flag-only rule escalates, because nobody looks at a
flag; a failing block rule does not block, because an evidence outage would
otherwise decline every payment.

Reference data refuses rather than guesses: an unloaded or undated jurisdiction
listing fails loudly instead of answering not-listed for every country on earth.

Dropped the empty-expression check, which the parser already makes.

22 of 22 mutations to the engine, history and reference guards are killed by the
suite; 58 of 58 across measure, sanctions and engine together.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 11:01:22 -07:00
zeekayandHanzo Dev 6a210f6402 sanctions: parse every list, and fail loudly when one yields nothing
Three of the four screening lists loaded zero entries, and nothing reported it.

Two sources were dead. The UK list pointed at the OFSI Consolidated List, which
closed on 28 January 2026 — ConList.xml has answered 404 since, and the UK
Sanctions List published by the FCDO is now the only source for UK designations.
The EU endpoint answers 403 unless its token is in the query string. Both had
been returning nothing for months.

The remaining two failures were structural. UN, EU and HMT were all parsed with
the OFAC parser, whose root element is sdnList — so a correct download of the UN
list still produced no entries. Each publisher now has a parser for its own
schema, resolved through one registry with no default case, because a fallback
to another publisher's parser is what made the emptiness look like data.

And the reason none of this surfaced: every failure was logged and skipped, and
RefreshAll returned nil regardless. A list that parses to zero entries is now an
error, a non-200 is an error, an unregistered source is an error, and RefreshAll
joins per-list failures into its return value. Stale reports data too old to
screen against, since a zero-match result against an empty list is
indistinguishable from a clean party.

Measured against the live files: 19,175 entries before, 32,518 after — OFAC
19,175, UK 6,315, EU 6,017, UN 1,011, every one of them named, with 15,117
dates of birth and 11,730 nationalities the previous path could not have read.

The fetch timeout moves from 60s to 5m: the EU file does not transfer inside a
minute, so the old value failed a healthy list.

Fixtures keep the quirks that break parsers — UN year-only and BETWEEN dates,
the EU's absent primary-name marker and "00" country sentinel, the UK's literal
"dd/mm" placeholders and six spellings of NameType, non-Latin-script names. Ten
mutants of these invariants, all killed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 10:59:22 -07:00
zeekayandHanzo Dev 039fa7e3d6 engine: a rule is admitted only if its evidence exists
The previous evaluator registered a stub for every term the language offered.
Absent a real implementation, sanctions screening answered false, geographic
matching answered true, the local hour answered noon, travel-rule completeness
answered complete, and chain risk answered zero. Six of the terms were later
replaced by store-backed versions; eleven never were, which left thirteen of the
twenty-three shipped rules structurally unable to fire. Measured on the shipped
code: a 50,000 dollar crypto payment at 3 a.m. from a politically exposed
customer in a listed jurisdiction, to a fresh address, naming a designated
person, raised four alerts out of twenty-three.

There are now no stubs. Terms are methods on the evaluation scope, each term is
mapped to the provider that answers it, and a rule naming a term whose provider
is absent is refused when the rule set is installed. The refusal names the term.
A rule set containing one such rule installs nothing, because a partially
installed set is a control surface nobody can describe.

Rules must yield a boolean. The previous evaluator accepted a number and read it
as truthy, so a velocity rule written as a bare sum fired on any activity at all.

Evidence failures at evaluation time — an unknown currency, an absent subject, an
unreachable store — are recorded on the alert and routed to review rather than
cleared, so an outage cannot become an approval.

Windows are fetched once per subject and lookback for the whole rule set rather
than once per rule.

Aggregation is over the transaction's own timestamp, not the row's insertion
time, and history queries are parameterised and scoped to the tenant. The
previous store interpolated the customer identifier into the filter string and
filtered by neither organisation nor transaction time.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 10:55:48 -07:00
zeekayandHanzo Dev 1d993f5c3d sanctions: matching that can clear a namesake
Name comparison is rune-wise. The previous implementation indexed folded names
by byte, which for two distinct Han names of equal length scores 0.867 —
above the 0.85 threshold, so two different people were reported as one. The
same names compared rune-wise score 0.700. Measured, not asserted.

Diacritics are removed by decomposing and dropping the combining marks. Letting
a mark fall through to the separator case splits the name: Oztuerk folds to
'o zt u rk' and matches nothing.

Cyrillic transliterates to BGN/PCGN, so zh, kh, ts, ch, sh and shch survive as
digraphs. The previous single-rune map sent both ts and ch to c and both zh and
z to z, merging distinct names.

Dates of birth are parsed rather than stored as text, across every shape the
publishers emit, and compared by ISO prefix so a year-only listing matches a
full date inside that year. A contradicting date of birth now costs more than a
perfect name match earns, which is what lets a namesake be cleared; the
conflict is recorded on the match rather than silently applied.

Aliases their publisher marks weak score below designated primary names.
Blockchain addresses and document numbers match exactly, never fuzzily, and case
folding follows the encoding: hexadecimal and bech32 ignore case, base58 does
not, because folding base58 declares two different addresses equal.

21 of 21 mutations to these guards are killed by the suite.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 10:51:07 -07:00
zeekayandHanzo Dev e6f58397ab sanctions: parse all four published list families
Each parser was written against the published file, not against a description
of it. Verified counts: OFAC 19,175 (matching the Record_Count the file
declares), UN 1,011, EU 6,017, OFSI 5,135.

What the previous single parser lost, and why each matters:

OFAC publishes 963 digital currency addresses across 19 networks in idList.
Dropping idList meant a payment to a designated blockchain address could not be
detected by any means.

OFAC marks 4,383 of its 24,546 aliases weak and the UN and OFSI mark theirs
Low; flattening them to plain strings matched fragments at full confidence.

OFAC flags which of several dates of birth is the main one, and for 37 subjects
it is not the first — taking the first dropped the tie-break for exactly the
subjects that need one.

UN, EU and OFSI were parsed with the OFAC parser on the stated grounds that the
formats are similar. They are not: the roots are CONSOLIDATED_LIST, export and
a CSV. All three failed every night, logged a line, and continued.

OFSI publishes one row per name and joins them by group; 19,761 rows are 5,135
subjects. It also puts the header on line two behind a preamble, orders the
name columns Name 6 first, and writes partial dates as 00/00/1994.

A parse that yields fewer records than the publisher declares is refused rather
than loaded, because a partial sanctions list clears designated subjects.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 10:38:47 -07:00
zeekayandHanzo Dev 366400f19e velocity: constant-time sliding aggregates, so structuring is detectable
The engine scored one transaction at a time against rules with no window state,
which means the typology regulators care most about was invisible: structuring
splits a payment into several below a reporting threshold, so every individual
transaction is unremarkable BY CONSTRUCTION. The EBA names it directly —
transactions "split to circumvent reporting limits" — and no point-in-time test
can see it.

Bucketed ring per (key, window). A bucket accumulates counters and the ring
rotates as time advances, so memory per key is FIXED rather than proportional to
volume, and a read is a few array adds independent of how many transactions
arrived. The obvious alternative — GROUP BY over history, per rule, per
transaction — is O(history) per evaluation and collapses exactly when volume
makes monitoring matter.

Measured: 83 ns/op and 1 alloc to record, 360 ns to read all four windows.
-race clean.

The cost is quantisation: a window of W with B buckets resolves to W/B, stated on
every Observation rather than left for a reader to assume away. Precision that
outruns the meaning of the number it measures is not precision — these thresholds
are themselves approximations of a regulatory limit.

Three decisions worth naming:

- Structuring is count-and-spread, not sum-over-threshold. Summing catches
  everyone who moves money in small pieces, most of whom are payroll. Clustering
  just UNDER a specific limit is the thing that only makes sense as evasion. A
  transaction AT the threshold is reported, not structured, so counting it would
  flag the honest case.
- Late events are folded to the leading edge and counted via Late(), never
  dropped. Losing an event from a compliance aggregate is worse than misplacing
  it in time, and silently doing either is worse still.
- Deviation is relative to this customer, because that is the statutory test.
  A flat limit flags every large customer and misses every small one behaving
  anomalously. No baseline reads as zero deviation: a new customer is not
  suspicious for being new.

Observations carry the numbers they were computed from — count, sum, the
near-threshold tally, the day spread — because a supervisor asks why an alert
fired and the answer has to come out of the system.

Mutation-proved 4/4: counting at-threshold as structuring, neutering window
expiry, dropping OrgID from the key, and removing the cardinality bound each turn
a test red. The expiry mutant initially SURVIVED and the test was the defect —
a 3h offset on a 1h/60-bucket ring is 180 buckets, and 180%60==0, so the new
event landed on the same slot and overwrote the old one. It passed on slot
aliasing rather than on expiry. Offset changed to 3h30m (210%60==30) and the
comment records why, so nobody tidies it back.

Written from the streaming-aggregation literature. No AML implementation was
consulted.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 10:18:05 -07:00
zeekayandHanzo Dev 3118a7dbb2 standard, history, measure: citations and pure behavioural measures
standard models a citation to a published requirement: authority, document,
locator, URL. A control that cannot name the standard it implements cannot be
defended to an examiner, so the library rejects an incomplete citation.

history offers one query — the subject's event window — so the tenant filter
lives in one place and every measure becomes a pure function of the window.

measure holds those functions. Structuring is the aggregation of sub-threshold
transactions reaching the threshold, not a band check on one amount. Currency
reporting aggregates a calendar day in the institution's zone, not a rolling
24 hours. Deviation uses the median and median absolute deviation because a
monitoring baseline is built from data that already contains the outliers being
looked for, and one past outlier inflates a standard deviation enough to hide
the next one.

15 of 15 mutations to the measure guards are killed by the suite.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 23:18:01 -07:00
zeekayandHanzo Dev 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]>
2026-07-28 22:51:25 -07:00