69 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 8f0cf7ac9f planes: the case plane signs with the credential too
Closing a case is the oldest governed act here and the one an obligation rests
on directly (AMLR Art. 69(2)): the retained assessment is what a supervisor
reads. Its decider was bound to the credential with the rest of them, and this
adds what was missing — the tests that say so, and the mutation that says the
tests can fail.

Two properties, both already true and neither proven: the retained assessment is
signed by the token's subject and not by the `by` a request body carried, and a
credential that names nobody cannot close a case at all. The second refuses at
the assessment, which is the record that matters, and it refuses as a REFUSAL
(400) rather than as an outage — a 503 reads as "retry", and this one never
clears.

The console still renders a "by" field on its resolve form. It is now ignored,
which is the point, but a form asking for something the API discards is a lie to
the analyst filling it in: ui/ owes a one-field deletion. That repo is another
track's.

Co-authored-by: Hanzo Dev <[email protected]>
v0.3.10
2026-08-01 16:55:32 -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]>
v0.3.9
2026-08-01 16:37:20 -07:00
zeekayandHanzo Dev cdf52d648f planes: three things that only looked right
The streak read was a fixed 5000-row page over a busy account's history, on the
ingest path, for a question that stops changing after the first few rows. It now
reads exactly as many as the deepest declared rung needs, which answers every rung
EXACTLY — each one asks whether the count has been reached, and a subject with more
than that in the window satisfies them all either way. Its upper bound is also
inclusive now: the activation is not written yet so it cannot match itself, and a
strict bound lost a prior one that landed in the same instant.

The list counters were read before the transaction and written inside it, so a
transaction the store retried would have counted twice. They are read from the row
inside the transaction now. A count that drifts upward is a list whose size nobody
can state.

And the field catalog formatted every numeric observation through fmt to hash it —
one allocation per numeric field per payload, on the ingest path, for a string
nothing reads.

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 2d940e06bb build: bump Go builder images to 1.26.5, set GOTOOLCHAIN=auto
Currency bump: golang builder images -> 1.26.5 (latest stable).

Adds ENV GOTOOLCHAIN=auto to each Go builder stage. The official golang
images set GOTOOLCHAIN=local, so a builder image older than the module's
go directive is a hard mid-build failure. The pin stays the hermetic fast
path; auto is the safety net for the next go.mod bump.

No go.mod go directive was changed. Builder images move; directives stay.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 16:26:55 -07:00
zeekayandHanzo Dev 9f1ea316b9 ui: the console is set in a typeface a browser has
It shipped in Times New Roman. Two independent causes, both silent, both found
by looking at the running console rather than by any check it passes:

The kit's default config names its families `System` and `Heading`. Those are
React Native names — that platform resolves them and this one does not — and the
kit writes the name straight into `--f-family`, which every component reads. A
family no browser knows is not an error; it takes the default, which is a serif.
So every control, heading and cell the kit owns was serif. The families are now
the web's own stack, given at createGui, so the kit emits it and nothing has to
be overridden downstream.

`--hz-font-sans` was declared in the theme bridge, which is on `.shell` and
`.gate`. `body` is outside both, so `font-family: var(--hz-font-sans)` there
substituted nothing, the declaration was invalid, and the document root fell
back to the same serif. A typeface answers to no theme — one stack serves dark
and light — so it belongs with the type scale at :root, which is where it is.

The invariant is in the kit suite, and it is mutation-proven: put either cause
back and it is the test that fails. It names the two stacks the console declares
and refuses every other resolved family, so the next family that means nothing
here fails the same way rather than needing its own case. 13 green.

Co-authored-by: Hanzo Dev <[email protected]>
v0.3.7
2026-08-01 15:06:10 -07:00
zeekayandHanzo Dev e835a29007 ci: the browser gets its libraries
`npm --prefix ui exec playwright install --with-deps chromium` downloaded the
browser and none of its shared libraries: npm's own parser consumed --with-deps
before playwright saw it, so the install ran as a plain `install chromium`. The
first run of this job that ever got past the typecheck therefore opened nothing —
7 of 12 tests died at browser launch on libnspr4.so, the whole signed-in console
suite among them, and the failure looked like a broken product rather than a
missing package.

Measured, not reasoned about: `npm exec node -p … --with-deps` reaches node
without the flag; `npm exec -- node -p … --with-deps` reaches it with one.

The console declares the command and CI runs it, which is what every other node
step in these workflows already does and the last `exec` here was the last place
a flag could be eaten on the way. Same shape as the tsc call one step above.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 14:58:25 -07:00
zooqueenandHanzo Dev d223e896f9 ci: the console job type-checks the console
`npm --prefix ui exec tsc -- --noEmit` ran the compiler from the repo root,
where there is no tsconfig. tsc printed its usage and exited 1, so the job has
failed on every commit since it was added and the browser suite behind it has
never run in CI once — the 12 playwright tests were only ever green on a
developer's machine.

--prefix selects the package, not the working directory. `npm run` executes a
script from the package root, which is how every other node step in these two
workflows already reaches ui/, and ui/package.json already declares the script.

Co-authored-by: Hanzo Dev <[email protected]>
v0.3.6
2026-08-01 12:58:05 -07:00
zooqueenandHanzo Dev f746f51ec4 release: the tag gets an image
No v0.3.x tag ever published one. Three defects, each measured on the runner
fleet rather than reasoned about:

- The daemon binary sat at /app/amld and alpine has no /app on PATH, so the
  smoke step exited 127 on `command -v amld` before anything ran. It installs
  to /usr/local/bin/amld now and the entrypoint names it, so the one name works
  from the entrypoint, from `docker run <image> amld`, and from a shell.
- Both smokes published a port and probed the loopback. The step runs inside a
  job container while the docker daemon is the runner pod's own, so the
  published port binds in the runner's network namespace and the loopback in
  the step reaches nothing — measured 000 against a console that was logging
  that it served. The probe container now shares the job container's
  namespace and is read on its own port; measured 200.
- xxd is not in the runner image. AML_TOKEN_KEY came from it and failed quietly
  to empty, because an env prefix on a backgrounded command reports the async
  list's status and not the substitution's, so ci.yml has been green for
  weeks against a daemon with no key. Hex comes from od/tr and the length is
  asserted before the daemon starts.

No product code changes: pkg/, cmd/ and ui/ are untouched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 12:46:35 -07:00
zeekayandHanzo Dev 2a7a9175ca ui: the kit reaches the screens, and what it renders has been looked at
v0.3.4 put @hanzo/gui behind the shell's primitives and left the six pages on
raw HTML: 58 controls and 90 arrangement divs that the kit's Input, Button,
TextArea and stacks were sitting there unused for. The pages compiled against
gui-backed wrappers, so every check passed and the console still wore a
lookalike. They are converted now — zero <input>, <button>, <select> or
<textarea> outside ui.tsx, and layout is a value at the use site rather than a
class to look up in app.css.

ui.tsx is the screens' whole vocabulary and the only place an exception lives.
Four are kept, each because the kit's answer would be wrong: the four state
colours (a state must mean the same in a screenshot), the meter (geometry is an
SVG attribute, not a style), the three auto-fit grids (the kit's stacks are
flexbox and flex-wrap strands the last item), and one native Select (the kit's
needs a react-native pan responder this bundle does not carry).

Looking at it found three defects that a build, a typecheck, a CSP run and a
render test had all passed:

  - `size` on a kit Button is a size TOKEN, so every label in the console was
    rendering at its button's height — 24px type in a 24px box.
  - Button does not forward className, so `tone` was dropped in silence: the one
    action a screen exists for looked like every other action, and the segment
    strips showed no selection at all.
  - The theme bridge sat at :root. A custom property is substituted where it is
    DECLARED, and the kit publishes themes as `:root .t_dark` — a descendant
    selector that can never reach :root — so the bridge read the kit's
    OS-preference default and handed every select a white ground and white ink.
    Also drops the Button sub-theme placeholders (`--t0: dark`) on the way: they
    are names, not colours, so tone reads the bridge instead.

Also found by looking: the panel body could not scroll, so a long case timeline
was unreachable, and the sign-in gate had lost its centring — both because CSS
addressed classes the components stopped rendering.

ui/e2e/kit.spec.ts is the guard, and it is a boundary AND a rendering check: it
reads these sources and fails on a raw control or a stylesheet arrangement in a
page, then measures a label, compares a primary button against a plain one and a
pressed segment against an unpressed one, and refuses any control that paints
its text in its own background colour. Written as invariants, not as three
regression cases, because the next one will not be the same bug. All six
mutation-proven: reintroduce the defect and the matching assertion fails.

12/12 browser tests green under the deployed CSP, signed in. The policy is
unchanged and still byte-identical to the chart. Go untouched: build, vet,
gofmt clean, 366 tests green under -race across 18 packages.

Co-authored-by: Hanzo Dev <[email protected]>
v0.3.5
2026-08-01 12:05:56 -07:00
zeekayandHanzo Dev 826ff1a773 ui: the console is the kit's, and the policy is proven rather than asserted
The console now renders through @hanzo/gui — the Hanzo design system — instead
of a set of primitives written here over the raw tokens. Layout, surfaces,
controls and type are the kit's. Two things deliberately are not: the four state
colours, which must not change with a theme because a supervisor's copy of a
screenshot has to mean what the screen meant, and the meter, which is drawn in
SVG so a width that varies with data is an attribute rather than a style.

The kit is imported at the part rather than at the barrel. @hanzo/gui's index
re-exports every component it has, including ones built on react-native
primitives this bundle never renders — Sheet's pan responder and the spinner's
ActivityIndicator are the two that pulled a native runtime into a browser build.
It is still one kit at one version.

The whole CSP question is now in ui/src/gui.ts, stated once. GuiProvider would
write the config's stylesheet as an inline <style>, which style-src governs and
which would need 'unsafe-inline' or a hash that moves whenever the theme does.
disableInjectCSS turns that off and the same CSS goes on through a constructed
stylesheet — CSP does not govern the CSSOM, because the script that reached it
had already passed script-src. The kit's per-component rules take the same route
into empty <style> holders, which the SHA-256 of the empty string allows and
which allows nothing else.

ui/e2e is the part that was previously a claim. It builds the bundle, serves it
under ui/csp.txt — byte-identical to the chart's HANZO_STATIC_CSP — seeds a
session before the first script runs, and drives Chromium through every screen
the rail offers. It carries a positive control: a style attribute parsed from
markup is asserted to be REFUSED, because "no violations" means nothing without
evidence that a violation would have been noticed. Writing it found two things a
claim would not have: the kit's theme root does carry a style attribute (React
writes it through element.style, so it applies and raises nothing — which is the
distinction that matters and is now written down), and a stray server on the
default preview port had been answering for a build nobody was testing.

origin() is now apiOrigin(protocol, host), a pure function with a table for a
test. It requires the first label to be `aml`, so evil.aml.hanzo.ai — a
four-label host the old rule read as a domain to derive from, and one that
resolves inside *.hanzo.ai — derives nothing. The certificate and the ingress
host rule were the only things standing there before.

@hanzo/iam is 0.21.5: logout ends the session (credentials + RFC 7009
revocation) and the callback error branch checks state before it reads the query
string. The console-side proof of the second is in the harness.

Co-authored-by: Hanzo Dev <[email protected]>
v0.3.4
2026-08-01 11:19:55 -07:00
zeekayandHanzo Dev 0faf15d14a cases: the record plane keeps no clock of its own
The case store evicted. Ninety days after a case was closed it was dropped,
with its timeline, once the store held more than a hundred thousand of them —
and both the count and the sweep were taken across every tenant at once, so one
institution's volume destroyed another institution's evidence, and neither of
them was told.

A case is the record that an alert was considered and what was decided, kept
whichever way the decision went (AMLR Art. 77(1)(b); JMLSG 6.32), for five years
(Dir. (EU) 2015/849 Art. 40). This repo already has a plane that keeps that
clock: pkg/retention counts in years, per tenant, refuses a date its own clock
has not reached, and proves what it destroyed before it reports a count. A
second clock here, counting in days, could not be reconciled with it — whichever
was shorter was the real retention period, and nobody wrote that one down.

So the durable shelf has no bound, and no constructor that could give it one.
What eviction remains is the memory shelf's, only this package's tests build it,
and it is scoped to one org.

The sweep is scoped at the seam rather than at its callers: shelf.each now names
the org whose cases it visits, so counting, triage and eviction are each a
tenant's own by construction. That closed two more crossings of the same
boundary that were not eviction at all — the stale-case sweep escalated every
institution's cases on any tenant's cycle, which is a write on another
institution's investigation, and least-loaded assignment counted another
institution's queue into this one's analyst load. Case numbering is per tenant
too: drawn from one sequence for the deployment, the gaps in one institution's
file references counted another's cases.

Opening a case now takes no census. The count that decided whether to evict was
a full scan of every case in the store, under the store lock, on every Create —
the path the replay gate calls the request that must not wait — and once the
store was over the threshold with nothing old enough to drop, every Create paid
two of those scans forever.

pkg/cases/evict_test.go is what says so, and each of these tests was checked
against the defect it describes: reinstating the bound fails the two durable
tests, un-scoping the sweeps fails the four tenant tests, and restoring the
census fails the ingest one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 10:51:30 -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]>
v0.3.3
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]>
v0.3.2
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]>
v0.3.1
2026-07-30 18:38:09 -07:00
zeekayandHanzo Dev eb4408ac3e ui: give the sign-in gate room
It is the first screen anyone sees. The line measure was the full card width
and the primary action was the width of its label.

Co-authored-by: Hanzo Dev <[email protected]>
v0.3.0
2026-07-30 18:11:22 -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 7aa70719c7 ui: the AML console
Six screens over the routes the engine actually serves: an overview that
publishes the coverage gaps beside the coverage, the case queue with its
timeline and a resolution that demands a rationale, a rule builder over the
engine's own vocabulary with the replay report, transactions with the
scoring path kept visibly apart from the recording one, screening that
shows what agreed and what conflicted rather than a score alone, and the
Art. 78 lookback drawn as a graph of the records the index answered with.

Sign-in is authorization code with PKCE against the brand's issuer, as a
public client. The bundle holds no secret and cannot: it is a static file
anyone may read. The issuer is not configured here either — it comes from
/v1/aml/config, which derives it from the Host, so the console can only
send a user to the issuer the engine will accept a token from. Deploy-time
configuration is two values (SPA_API, SPA_CLIENT_ID), templated into
/config.json by the static server before the first request is answered.

Surfaces, ink, radii and type are @hanzo/tokens — the canonical Hanzo
design tokens, the values @hanzo/ui's theme.css derives from — so this is
the Hanzo identity and not a lookalike, at one dependency with no runtime.
State colour is the four reserved steps and never carries meaning alone;
every badge ships with its label. Magnitude is drawn in SVG rather than
styled, so there is no inline style in the app and the served CSP needs no
'unsafe-inline' for style-src.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 17:57:48 -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 30c8d7d1e8 ci: build the image with plain docker, not the buildx container driver
The release build failed on the native runner at buildx setup — the pure-Go test
job on the same runner passed, and the build job produced no step log, which is
the signature of a failure before any step: setup-buildx-action with
driver: docker-container nests a buildkit container inside the runner's DinD, and
that nesting is what broke. The runner's own docker daemon works (confirmed), and
this publishes a single linux/amd64 image, so plain docker build against the
daemon does the whole job without the extra layer that only added a failure mode.

Co-authored-by: Hanzo Dev <[email protected]>
v0.2.0
2026-07-30 10:03:36 -07:00
zeekayandHanzo Dev f9b25476ee ci: the smokes must match the hardened auth, or every build fails
The white-label hardening changed two things the CI and release smokes did not
account for, and both were failing every run:

- amld now refuses to start without AML_CLIENT_ID (it is the audience every token
  is pinned to; a plane that cannot check the audience authenticates any
  first-party token). Both smokes started it without that env, so it exited
  before answering. Both now set it.
- ingest now requires a brand-issued IAM bearer, so the release smoke's
  "a header-only $100 payment comes back allow" assertion can no longer hold —
  CI cannot mint an IAM token. That check moves to the engine's own tests, where a
  token can be signed; the smokes now exercise only what needs no token: the
  process serves /v1/aml/health, and an unauthenticated read of /v1/aml/cases is
  refused 401.

Also: the CI build dropped -tags embedui. The embedded operator surface needs
ui/dist, which the Dockerfile's UI stage builds and which is not committed, so the
tagged CI build failed on a missing embed pattern. CI proves the Go code compiles
and runs; the release image proves the embedded UI.

Verified locally against the hardened binary: health 200, unauthenticated cases
401.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 23:16:09 -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 f7df6eb71e docs: state what the auth path actually checks, and register what it documents
GET /v1/aml/config was documented as live while Register never mentioned it, so
the only thing serving it was a test calling the handler directly. It is
registered (previous commit) and the table is true.

The rest of the section described an engine that took `owner` on faith and fell
back to Hanzo for an unrecognised Host. It now states the four checks a token
passes, why each one exists, the tenant key's shape and why the vault salt is
the sharp end of it, and the environment a deployment has to supply — including
that AML_CLIENT_ID has no default and the daemon refuses to start without one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 22:31:46 -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 3e432c742e ci: trigger the canonical build on the native runner
Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 18:45:07 -07:00
zeekayandHanzo Dev bb53740bd5 ci: mirror the image to oci.hanzo.ai as the sovereign copy
git.hanzo.ai is the canonical home and GitHub the mirror; the image build runs on
our own native runner, never GitHub's. The sovereign registry copy at
oci.hanzo.ai is added as a best-effort-loud step: it authenticates against Hanzo
IAM (a different credential from ghcr), and until that credential is provisioned
the step announces the skip rather than failing the release or silently dropping
the copy. ghcr.io/luxfi/aml carries the credentialed build today; oci becomes the
canonical target once its IAM registry credential lands.

Co-authored-by: Hanzo Dev <[email protected]>
v0.1.0
2026-07-29 18:36:46 -07:00
zeekayandHanzo Dev 584c1aa852 ci: publish ghcr.io/luxfi/aml, not a mixed registry
The container-registry rule is not mixed: a Lux product publishes under
ghcr.io/luxfi/*, the way luxfi/kms does. oci.hanzo.ai was the wrong turn — it
mixed registries and, decisively, has no credentials provisioned on the forge,
while GHCR_USER/GHCR_TOKEN are present. The path that actually builds beats the
one that reads well.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 18:32:26 -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 7392747d24 tools: resolve the module root from the harness, not from its own directory
The harness derived the module root from its own location, so moving it into
tools/ pointed every path at tools/pkg/... and it failed on the first mutation.
A mutation harness that cannot run is worse than none: its output is the evidence
that a test can fail for the right reason, and an unrunnable one leaves that
claim resting on when it last ran. 30/30 killed from the new location.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 11:17:45 -07:00