mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
main
16
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b99d35dbed |
planes: the statistical plane is shadow until somebody arms it
api.Deployment carried `Shadow bool`, whose zero value is false — so an assembly that said nothing about the behavioural model got one contributing to verdicts. cmd/amld set it correctly, which is exactly why nothing would have caught it: the default only bites whoever writes the next Deployment. Stated from the LIVE side, the zero value is shadow. Detection has to be testable before it is activated — a new deployment scores, learns and publishes at GET /v1/aml/anomaly what it WOULD have alerted on, and contributes nothing until somebody has read that. A default that arms a statistical control for anyone who did not think about it is the one direction a default must not fail in. Found in my own work in this cycle, not reported by the review. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
1b7a28d31f |
planes: one assembly, one identity for an offer, and a bound nobody can spend twice
The defining failure of the last review was not in a plane. It was that there were TWO wirings: cmd/amld assembled a Handler by hand and the tests assembled a different one, so the record fingerprint could be a struct field the suite exercised and no column stored — every retry a permanent 409 in production while every test passed. api.Wire is now the only assembly. It creates every collection, opens every shelf, makes every join and registers what has to run on a cadence; cmd/amld supplies only what an installation answers differently, the tests call the same function, and internal/source.NoLiteral reads cmd/amld to refuse a second copy. Handler completeness is checked by reflection, because a hand-written field list would be the third copy. A retry is not a second transaction. pkg/receipt resolves the identity of an OFFER once, at the top of ingest, before anything is read or written, and returns the prior answer byte for byte — so the ledger, the aggregates, the history, the alerts and the case plane all agree on one transaction instead of the ledger holding one record while the aggregates hold three. Two different facts under one reference is 409 and never 503. Because the answer is kept AFTER the work, a process that dies in between leaves no receipt, so every plane past it also recognises the transaction for itself: history does not append a second row, alerts keep the first judgement, and a judged transaction opens no second case. A bound over a COUNT of caller-sized values is not a bound. types.MaxIdent bounds every identifier at the door, which is what turns the counts downstream into figures in bytes; dictionary.MaxName does the same for a payload key, and dictionary.Ceiling is the product a test WEIGHS a full accumulator against. The field catalog's accumulator moves into roster, which admits and never removes, so per-tenant bounds multiply by ONE number an operator can hold against a pod. engine.Evaluator now keeps no compiled rules at all. A candidate's text arrives on the wire at /v1/aml/rules/test, so a compiled-rule table there was keyed by a string a caller wrote, sized by how many distinct ones a caller sends, with no cap and no removal, in the memory every institution's ingest runs in — an OOM from one authenticated caller, which on this deployment is every control off at once. A rule's compiled form is a value with an owner: the installed library, or one replay. Nothing shared, nothing to grow. Found here, not reported. Handler.history is the one function that materialises a whole retained history, so it now REQUIRES a *topology.Grant, and a Grant comes from Budget.Admit and nowhere else. An ungated whole-history read is something a caller has to write on purpose rather than something a new door can forget. The rule replay and the activation rate fold take the same one budget the model studies take. Reaching a bound is allowed; reaching it quietly is not. GET /v1/aml/load is the one door for "is anything of mine quietly degraded": the tenant's aggregates against its own ceiling in bytes with a grade, whether it has a behavioural model at all, the field catalog's pressure, and the process's counts of institutions held and refused. It names no other institution. Closing R1-R8 from the review plus R11, the two recurring classes, and the silent degradation the load door answers. Twenty-four mutations, twenty-four killed. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
7de786224f |
planes: six ways a monitoring plane could be turned against the institution running it
Each of these is a control that worked and could be made not to, by a caller holding an ordinary token for its own tenant. Every one carries a test, and every one carries a mutation in tools/mutate.py, because a bound with no test is a bound that comes back. THE EXPENSIVE PLANE COULD TAKE THE MACHINE. A model search replays a tenant's history through up to MaxTrials detectors and a fit replays it through one; both were registered plain. Four bounds now, each doing one thing and nothing else: one study per tenant (api.gate), a deadline derived from the request's own context so a client that goes away cancels the work (api.within, 2 min), a process budget whose tokens ARE the trial workers and are taken BEFORE the history is read (topology.Budget, half the cores), and a clamp on the width one request may name (MaxWorkers). The gate is a map keyed by the tenant with nothing ever evicted from it, so one institution's study is never another's refusal; the budget is the one bound that is not per tenant, deliberately, because the CPU is shared however it is modelled and waiting for the machine is not the same as being refused because of somebody else. A rule replay is admitted the same way. Fit now records what it cost, as a run already did. THE DECIDER WAS FREE TEXT. Every plane asks a governed act for a reason and a decider so a supervisor can be told who turned a control off, and the answer was whatever the caller typed. types.Decider is a distinct type carrying `json:"-"`, so no wire format has a name for one, and pkg/api/typed.go writes the AUTHENTICATED SUBJECT onto every field of that type after the body and after the path. Identity now yields a Caller — two projections of one verified credential, the tenant that indexes every store and the subject that names a decision, both from the same signature. A credential naming nobody still authenticates, because reads and ingest are the tenant's work and not a person's, and refuses every operation that would record a decision. A TENANT COULD STOP ITS OWN PAYMENTS, AND THE RETRY DOUBLE-COUNTED. suppress.Cover runs once per activation on the ingest path and failed closed past MaxCandidates, which h.monitor turned into a 503 on every transaction. It degrades now: the answer is over what was read and says so (Cover.Partial, Activation.Unchecked), and the crowding is refused at DECLARATION instead (MaxInForce), where a refusal costs one operator request rather than every payment. The retry that followed such a 503 wrote a second activation for one firing, which counted in the streak, so a declared repetition policy fired on a repeat that never happened — a payment blocked because the client retried. An activation naming a transaction is now identified by (tenant, transaction, rule, subject), hashed so no customer value is in a key. The retained record is idempotent by the same reasoning: token.Vault.Fingerprint names the plaintext deterministically, since a seal draws a fresh nonce and sealed bytes never match; and the transaction now carries ONE clock, its own. The reception clock ingest stamped made every retry a different fact under one id, which no retry could ever clear. A genuine conflict answers 409 rather than 503, because 503 reads as "retry" and a client retrying a permanent refusal retries forever. A RUNG'S COUNT WAS THE LIMIT OF AN UNBOUNDED READ. It becomes a store LIMIT on the ingest path, so it is bounded at declaration (MaxCount, MaxWithin) and clamped again where it is used, because a bound introduced after a row was written does not reach that row. THE CATALOG STORED PAYLOAD VALUES AFTER ALL. A minimum IS a value, exactly, at any volume, and at a count of one so are the sum and the mean. Declared numbers are the transaction model's own and keep their moments; a custom number is whatever the institution put under its own key and gets the sketch and nothing else, absent rather than zero. And the vocabulary itself is now bounded PER TENANT, in the accumulator and in the rows alike: MaxKeys bounds one payload used as a bag, it does not bound a tenant sending a different key on every transaction, and that growth was in the memory every other institution's ingest runs in. Reaching the bound degrades only the tenant that reached it, refuses no payload, and publishes what it turned away. AN ADOPTED CONTROL COULD GO QUIET WITH NOBODY DECIDING IT SHOULD. The fit is durable and the adoption is durable, but what adoption DOES is install learned state into a model in memory — and this is one replica with a Recreate strategy, so a rollout empties it, and the store holds a bounded number of tenants' models, so an idle institution's can be dropped for a busy one's. Either way the tenant returns to warming, which reports no alerts, which is what a quiet institution also reports. The model now asks the model plane what this tenant last adopted when it plants a model for it, and State publishes when the model started and whether it started from an adoption, so a reset is visible instead of inferred from a silence. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
7c314d0b30 |
planes: the five surfaces a monitoring programme is operated through
Lists, suppressions, activations, the field catalog and the model plane. Each is its own package, each is Base-backed, and none of them can destroy anything — disposal is pkg/retention's decision alone, per tenant, and every one of them has a test that reads its own source for a call to Delete. lists holds the institution's own allow and deny lists, by class, normalised once so an address written two ways is one address. It reaches a rule as one term, Listed(name, value), and it refuses both a list nobody declared and an empty value: either would be a rule that is catalogued, reported as coverage, and incapable of firing. Removing a value stops it matching and keeps the row, because a list entry is the reason a payment was refused. suppress holds the decisions to keep a detection out of a queue. Reason and decider are required and neither is defaulted; a suppression naming neither a rule nor a subject is refused, because that is a kill switch. Lifting keeps the row. The narrowest cover wins, so a specific decision about one account is the one named on the activation rather than the rule-wide one behind it. watch writes every activation before anything happens to it, and marks the ones a suppression covered or a fold matched rather than dropping them: a system that discards what it decided not to show cannot say how much it is not showing. Action is what the rule asked for, Response is what the plane concluded, and exactly two declared mechanisms move it. A rung may only raise — lowering is a suppression by another name — and elevation beats folding, because an activation that reached an escalation rung is the escalation. The live feed is a convenience over the durable rows; it drops rather than blocking ingest, and the drops are counted per tenant and published on every poll. dictionary reports what a tenant's payloads carry: declared fields discovered by reflection over the transaction rather than from a second list, the tenant's own Raw keys, fill, blindness, and a distinct count kept as a bitmap sketch — hashed with the tenant, so two tenants' bitmaps cannot be compared, and saturating into "no longer a count" rather than drifting down. No value is ever stored. It is write-behind and says so: Pending is exactly what a restart would lose. topology searches the space of model shapes over a tenant's own history, through the detector's own learning path rather than a copy of it, and reports the learning curve, the realised alert share against the stated appetite, where each candidate went blind, and which features carried the signal. It has no store and imports nothing that has one. It names NO winner where nothing has been judged: ranking needs an outcome, and a ranking invented from unlabelled data would look like a recommendation and be a preference. Ties go to the smaller model. models keeps every search and every fitted state, because both are governed acts, and adopts a fit into the live model with its decider recorded. The learned state never leaves the store — mass counters describe where a tenant's activity is dense. A fit installs only into a model of the same shape, which is what stops a tenant's model being swapped underneath it by restoring a file. Around them: anomaly.Learn exposes the assessment Assess was already computing and throwing away, so a study of the model reads the same scores production does; engine gains one provider and one vocabulary term; and the tenant key moves to pkg/brand, where the brand registry is, so the five new planes hold the same shape at their own write boundary instead of each growing an opinion about what a tenant is. api/tenant.go's qualify and qualified are now aliases of it, not a second copy. Every operation is func(ctx, org, *In) (*Out, error): the tenant is an argument and never something the operation resolves for itself. api/typed.go is the whole of what turns one into a route — query into the input for a GET, body for a POST, the path bound last and winning — and api/planes.go is a routing table with no handler bodies in it, which a test reads as source. The contract is the Go types and nothing else, so a zip face over the same functions restates none of it. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
9b7352ae4a |
aml: auth is @hanzo/iam, and the console is configured by nothing
Two changes that are really one: stop owning things that are already owned. AUTH. Deleted ui/src/auth.ts — a local PKCE implementation with its own token storage, its own refresh, its own state handling and its own idea of what signing out means. @hanzo/iam/browser is that, for every Hanzo app: configureIam + startLogin + handleCallback + getSession + getUser + logout. There is now no auth code in this repo to review, drift, or get wrong, and when IAM's end-session behaviour improves every app gets it without a line changing here. Verified against production IAM: hanzo.id reflects https://aml.hanzo.ai on discovery, token and userinfo, so the flow works from the console's origin. CONFIGURATION. Dropped config.json and the SPA_* env with it. The console derives its API from the host it is served on — aml.hanzo.ai can only ever talk to api.hanzo.ai — and reads its brand, issuer and IAM clientId from that API's /v1/aml/config, which now carries client_id because that route already answers 'which identity does this surface have'. So there is no setting that points authenticated requests somewhere, which is the setting worth attacking; the same image is every brand's; and nothing writes the served root, which is what lets the container run read-only and unprivileged. The derivation is deliberate about what it refuses: an address literal has three labels and no parent domain, so 127.0.0.1 would have become api.0.0.1. Every host shape is asserted — brand hosts, a bare domain, localhost, IPv4, IPv6. Also here, from the same review: one replay per tenant on /v1/aml/rules/test, because an empty sample opens every retained record and a handful at once occupies the engine that still has to answer ingest; and the CI smoke now asserts the invariants that replaced the old ones — no config.json is served, and the bundle names no clientId. CSP unchanged and re-proven: 7 routes, zero violations, zero console errors, still no 'unsafe-inline'. Go build/vet/gofmt clean, 18 packages green, api race-clean. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
6024f38a0f |
alerts: the last of the record leaves memory
An alert is what a rule said about a transaction at the time it was judged. The case that cites it is durable now and so is the record it was raised against, so an alert store that empties on a rollout leaves a case naming evidence that is no longer there — and the replay that measures a rule's false-positive rate reads the same alerts to know which rules actually fired. Same shape as the case plane: an app means the durable shelf, no app means the map, and the map keeps its LRU bound because memory does. The durable shelf does not evict; an alert is evidence, and the retention plane is what decides when evidence goes. One JSON column rather than a column per field. An alert is only ever read whole — the route hands back a transaction's alerts, the replay reads which rules fired — so nothing filters on a score or a citation, and a column per field would be a second place the shape of an alert is declared. TestAlertsSurviveARestart writes two tenants' alerts on one transaction, takes the bytes, opens a second instance and reads them back per tenant: the org is what scopes the read, and it still is on the other side. All three planes are now durable and each has its restart test. LLM.md carries the table. Full suite green (18 packages). Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
df3cc926ea |
cases: the timeline is readable
The events a case accumulates were written and never read: AddEvent had a
route, Events had none, and the store method had no caller. A case whose
history cannot be read back is not reviewable — the note recorded under
AMLR Art. 77(1)(b) is evidence only if somebody can see it.
GET /v1/aml/cases/{id}/events resolves the case through caseOf first, so
the timeline carries the case's own tenancy: another tenant holding the id
gets the same 404 as a case that was never opened. The store is keyed by
case id alone, which is safe only because nothing reaches it without that
check.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
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]> |
||
|
|
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]> |
||
|
|
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]> |
||
|
|
5736ea8168 |
anomaly: record the measured per-tenant cost and the design in LLM.md
The memory bound in Config was an arithmetic estimate; it is now the figure a test measures (336 KB per tenant at the defaults, 84 MB at MaxOrgs=256), so the number an operator sizes against and the number the code enforces are the same one. LLM.md gains the reasoning a later reader needs and cannot recover from the code: why half-space trees rather than isolation forest or robust random cut forest, the two places this implementation departs from the paper and what each departure buys, why attributability rather than accuracy decided the choice, and what the appetite governs. The departures matter most — averaging clamped per-tree votes instead of summing raw masses is the difference between a score that ranks and a score a quantile can be cut from, and a reader who assumes the paper's aggregation would reintroduce the fault. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
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]> |
||
|
|
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]> |