21 Commits
Author SHA1 Message Date
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 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]>
2026-07-30 17:57:36 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 23:11:08 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 22:40:43 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 22:37:10 -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 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 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 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 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 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 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 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