16 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 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]>
2026-08-02 11:45:32 -07:00
zeekayandHanzo Dev 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]>
2026-08-02 11:32:15 -07:00
zeekayandHanzo Dev 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]>
2026-08-01 16:37:20 -07:00
zeekayandHanzo Dev 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]>
2026-08-01 16:37:20 -07:00
zeekayandHanzo Dev 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]>
2026-07-30 19:18:57 -07:00
zeekayandHanzo Dev 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]>
2026-07-30 18:57:41 -07:00
zeekayandHanzo Dev f7ff1b6e4d cases: the case plane is durable
The other half of the record. Cases and their timelines were two maps, so an
investigation lasted until the pod did — and 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). A rollout took the evidence of the
work with it: who looked, what they found, what they concluded.

Split the state onto a shelf, the way the retention ledger already is: one
interface, a memory implementation for the tests, and a Base-backed one for
what an instance serves from. Two collections, aml_cases and
aml_case_events, declared as store.Kinds so every read is tenant-scoped and
every write carries an org.

Three things the maps got for free and the shelf has to earn, so each is
asserted: the case number continues across a restart rather than starting
again at one, which is what stops two cases sharing a number; Get is
deliberately unscoped because the caller holding the id is the one that knows
whose it should be, and putting the check in two places makes the second one
the easy one to forget; and eviction still only removes cases closed longer
ago than the window, so an open investigation is never dropped to make room.

TestCasesSurviveARestart takes the bytes as they are on disk, drops the
instance, opens a second one over them and asks for the case, its note and
its author — then checks the other tenant still cannot read or write it, that
the next case is number two, and that a resolution sticks. Full suite green
under -race.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 18:51:53 -07:00
zeekayandHanzo Dev 4aa9887950 retention: the record plane is durable
cmd/amld built the ledger with retention.New() — the memory shelf, whose own
header says it 'does not survive a restart, so it is not what an instance
serves from'. retention.NewBase(app) has existed all along and had no caller,
while the transaction history thirty lines below was already Base-backed.

So every retained record — the sealed transactions, the relationships whose
clocks Art. 77(3) starts, the assessments a case is closed against — lived
until the next rollout. A restart is not an event the five-year obligation
makes an exception for.

Wired the same way history is: collections first, then a ledger over them,
inside OnServe where the app exists. TestRetainedRecordsSurviveARestart is
the property as a test — retain, take the bytes as they are on disk, drop the
instance, open a second one over them, and ask Art. 78's question again.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 18:38:09 -07:00
zeekayandHanzo Dev a80bbcf3b4 aml: the API and the console are two deploy units
The daemon was serving its own dashboard from /_/aml/ inside the same pod
and the same image. That is one host doing two jobs: the API belongs at
api.<brand>/v1/aml, one door for data, and a console belongs on its own
host as a compiled bundle. Sharing a pod shares a blast radius and a
rollout, and the embedded copy was unreachable from outside in any case.

ghcr.io/luxfi/aml is now the daemon alone — no UI stage, no embedui tag, no
embed seam. ghcr.io/luxfi/aml-ui is ghcr.io/hanzoai/static with the bundle
in /public and -spa, so a hard refresh on /cases resolves. Both are built
by the release workflow under one tag, so a version names one state of the
product; the console's smoke proves it serves, that SPA_* reaches
config.json, and that a deep link falls back to index.html. CI typechecks
and builds the bundle on every branch, so a type error fails before a
release rather than during one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 17:58:01 -07:00
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 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 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 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 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 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