typescript 7.0.2 is the native compiler, so this is a pin and not a second
toolchain. TS7 reports a side-effect css import as TS2882, so src/vite-env.d.ts
now references vite/client for the ambient asset modules; that is neutral on 5.9.3.
ui tsc -b over 13 files: 0 diagnostics before and after.
Co-authored-by: Hanzo Dev <[email protected]>
The shipped default fetched Zen from cdn.hanzo.ai. Nothing publishes it there:
every /fonts/zen/* path answers 404, while /fonts/geist/1.7.2/ still answers 200
because those bytes were put there by hand. So every first paint of this console
asked for two files that are not there and fell to the system stack — in a
compliance product whose own tests assert it is set in Zen.
The self-hosting path already existed as FONT_ORIGIN=, and it is now the
default: vite emits the two faces out of @hanzo/font at the same paths, served
same-origin. The CDN stays reachable through FONT_ORIGIN for the day the fleet
publishes there, and the URL contract is still pinned so a rename cannot pass
unnoticed.
Three things follow, and each is a tightening rather than a trade:
font-src drops its host. A cross-origin face has to be named in the policy; a
face we carry does not, so the cheapest possible widening of a CSP is now no
widening. csp.txt and the chart's copy move together, as they must.
The cross-origin assertion gets stronger. It used to allow exactly one origin,
ours; it now expects the set to be EMPTY — no subresource loaded from anywhere
but here. An origin nothing fetches from cannot serve the wrong bytes, go down,
or be asked for a beacon.
And the pin moves to @hanzo/font 1.9.2. 1.9.0 and 1.9.1 shipped a zen.css whose
:root block was missing, so the two custom properties a non-Next consumer reads
were undefined — which computes to an invalid value and falls to the OS stack,
silently.
The kit moved to Zen — @hanzogui/font-zen names the families and @hanzo/font
ships the bytes — but only half of this console followed. src/gui.ts took the
stacks from the kit, so every rule asked for "Zen" and "Zen Mono", while
src/font.ts still fetched Geist from /fonts/geist/1.7.2 and registered those
bytes under the name the kit had just changed. A face installed under a name
the file inside it does not carry is the failure this suite exists to catch,
and font.spec.ts was left asserting Geist on both sides of it.
Delivery now reads the same package the kit points at: the self-hosted build
and the suite both take their bytes from @hanzo/font rather than from `geist`,
and the licence emitted beside them is that package's, which carries Vercel's
notice as well as ours because Zen is drawn from Geist. offline.spec.ts checks
for both holders now, since dropping the upstream line is the same defect as
dropping the licence.
The CDN does not yet publish /fonts/zen/1.9.1. Until it does, ship this with
FONT_ORIGIN= so the bundle serves the two files from the app's own origin.
The serif is gone, but what replaced it was the machine's own sans — whatever
system-ui happens to resolve to, which is a different face on every desktop the
console is opened on. Geist Sans is the UI face and Geist Mono the monospace one
across every Hanzo property, so this console is set in them too, and fetches
them from cdn.hanzo.ai: one copy, one version, one cache entry shared with every
other property, and no request to a font host that is not ours.
The stacks are NOT written down here. They are read from @hanzo/gui's font
packages, because a stack copied into an app is a second source of truth that
does not fail when the kit's changes — it quietly stops matching the other
hundred properties. src/font.ts owns only DELIVERY: which release, which files,
which origin, and the @font-face rules. The face is registered under the family
its own stack leads with rather than under a name repeated beside it, so the
face installed and the family asked for cannot drift apart.
Three faults were found on the way and are each fixed and each pinned:
- The kit config had no `mono`, and ui.tsx asks it for `$mono`. A font token
the config does not define resolves to nothing.
- `.mono` lost to the kit. The kit dresses its own with
`.font_body, … { font-family: var(--f-family) }` — one class, in an adopted
stylesheet the cascade places after app.css — so a bare `.mono` tied on
specificity and lost on order, and machine text asked for through the kit
came out in the UI face: an id or a rate in proportional digits, in a
column, which is exactly where it shows. `:root :is(…)` settles it on
specificity, which no ordering can undo.
- The preload needed `crossorigin` even where the origin is our own. A font is
always fetched in CORS mode, and a preload that disagrees is a second
download rather than a saved one.
The policy gains one host in one directive — `font-src 'self'
https://cdn.hanzo.ai` — and is byte-identical otherwise. No unsafe-inline, no
unsafe-eval, no second directive, and the CDN may serve this console faces and
not code, style, or a place to send data. The faces go on through the CSSOM with
the kit's stylesheet, so style-src still sees no stylesheet parsed from markup.
An install that cannot reach us builds with `FONT_ORIGIN=` and serves the same
two files, at the same paths, from its own origin, with the OFL text beside
them — a build argument, not a fork. It is built and driven in the suite rather
than reasoned about, because an escape hatch nobody exercises is found broken by
a customer, on an install, with no network to debug it over.
Geist 1.7.2, Vercel and basement.studio, SIL Open Font License 1.1 — confirmed
against the licence shipped with the exact files hosted, which are the pinned
`geist` package's and not a copy of them.
Every assertion is mutation-proven: put back the serif-era selector, the old
policy, a drifted filename, proportional figures or a bare preload, and it is
the named test that fails. 22 green. Bundle +1,808 B; the two variable faces are
141 kB off-bundle, fetched once and cached for a year.
Co-authored-by: Hanzo Dev <[email protected]>
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]>
`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]>
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]>
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]>
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]>
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]>
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]>
The defect that hid two dead endpoints for months was not that they were dead. It
was that nothing could be asked whether they were alive. Screening failure is
silent by construction: an empty list matches nobody, and neither does a clean
party, so "no designations matched this payment" is the same answer either way.
Monitor keeps the outcome of the last refresh of each list — entry count, load
date, payload digest, and the reason if the last attempt failed. A failed attempt
keeps the previous successful load rather than erasing it, because that data is
still what screening runs against; but it ages out instead of staying fresh
forever on the strength of one old success. A list that has never loaded is
reported as unfit, not omitted.
Ready is all-or-nothing on purpose. Screening against three of four lists is not
three-quarters of a control: a party designated only on the missing list passes
completely, and the result is indistinguishable from a clean one.
GET /v1/aml/sanctions/sources serves this and answers 503 when any list is
unfit, so a readiness probe and the dashboard read one truth instead of each
deciding for itself. The dashboard panel leads with whether the instance can
screen at all, above the case counts — a queue length means nothing if the lists
behind it are empty — and states in words that a result against unfit lists
cannot be read as clean.
Six mutants killed, including the two that matter most: treating a zero-entry
load as fresh, and letting three-of-four report ready.
Co-authored-by: Hanzo Dev <[email protected]>
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]>
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]>