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]>
One toolchain across the estate. The tree carried 1.26.0, .1, .3, .4 and .5
at once, so which compiler built a binary depended on which repo it came
from.
Co-authored-by: Hanzo Dev <[email protected]>
hanzo-build-linux-amd64, lux-build-linux-amd64, zoo-build-linux-amd64 and
every other org-prefixed spelling all meant the same machine. Which org a
job belongs to does not change which host can build it, so the org bought
one pool name per org per arch and each could go stale on its own -- the
evo host alone answered to fourteen names for one label.
One vocabulary now: linux-amd64, linux-arm64, macos-arm64, win-amd64. The
generic labels every plain workflow uses (ubuntu-latest, self-hosted) are
untouched, so naive CI keeps working.
Co-authored-by: Hanzo Dev <[email protected]>
The lab runs amd64 and arm64, so an amd64-only tag starts on half the
fleet and the other half never pulls a runnable image. The daemon build
fans over the two native pools: each job compiles and smokes the image on
the machine it ships to — nothing is emulated, so the smoke answers "does
this run there" rather than "does QEMU agree" — pushes
ghcr.io/luxfi/aml:sha-<sha7>-<arch>, and a merge job joins the pair under
the release names, proving every one of them carries both platforms before
it goes green. The sovereign copy is made with imagetools, which copies a
manifest list; `docker tag` + `docker push` would have mirrored one
architecture.
The console stays amd64: ui/Dockerfile serves the bundle from
ghcr.io/hanzoai/static:v0.5.1, a single amd64 manifest that
`--platform=linux/arm64` cannot resolve. That tag, not this workflow, is
what has to move first.
Lux Partners Limited is the Isle of Man company and does not appear on a
public surface. Lux Industries Inc, a Delaware C corporation, is the one
these copyright lines, citations and terms name.
A bare "Lux Partners" is left alone unless the line says who owns
something — elsewhere it is the name of a site, not a party.
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 statistical plane lands DISARMED. api.Deployment states the field from the
live side (Live bool, not Shadow bool) so the zero value is shadow: an assembly
that says nothing about the behavioural model gets one that contributes to no
verdict. cmd/amld is unchanged in effect — AML_ANOMALY=live is still the only
thing that arms it — so the running v0.3.7 deployment behaves identically and
only the default for a NEW Deployment moves, toward shadow.
Surface is /v1/aml throughout. Nothing here publishes under /v1/ml, which is
the model-serving plane, or /v1/risk.
Co-authored-by: Hanzo Dev <[email protected]>
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]>
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]>
The tie fix landed with only half of it pinned. pava pools tied scores, and a
test proves it; the dedup that follows pools two blocks whose centroids ROUND to
one score, and nothing proved that — reintroducing the last-wins pick left every
test green.
The collision is not exotic: round6 collapses anything inside 5e-7 and a decision
score is round4'd before it arrives, so two blocks that differ in the ninth place
are one place on the map. Keeping either endpoint states a probability the pooled
evidence does not support — 2-in-10 under, 8-in-10 over, and the map says 0.8
where the rows say 0.5.
Co-authored-by: Hanzo Dev <[email protected]>
Two shapes, both on the ingest path, both proven by measurement.
A bound over a COUNT of caller-sized things is not a bound. A velocity key
is not a pointer to what the caller sent: it is one ring per window, 444
buckets allocated whole on the first transaction, a measured 22.7 KB. So
MaxKeys=100000 meant 2,163 MiB against a 1 GiB pod — the honest reading was
that the process dies at about 46,000 keys, from one tenant, with ordinary
traffic. The ceiling is now in BYTES and the key count is derived from it in
one place (keyCost), so the two cannot disagree; Store.Ceiling() is an upper
bound a test WEIGHS a full store against.
One store shared by every tenant under one cap is a way for one institution
to switch another's monitoring off. Measured: a victim's 24h window went
count=1 to count=0, and a victim's model went learned=40 to learned=0, purely
from two other tenants' traffic — no error, no alert, and an aggregate that
reads zero is what a clean account also reads. Per-tenant state now lives in
pkg/roster, which ADMITS and has no removal at all: a tenant keeps what it
holds, a tenant arriving with no room is refused by name (anomaly's
ReasonCrowded) and counted, and the only key a tenant can drop is its own
least recently used. internal/source reads both packages to keep the shape
out.
Reaching a bound is allowed; reaching it quietly is not. velocity.Load grades
a tenant clear/crowded/full and counts what it dropped, anomaly.State carries
Crowded and Pressure counts it, and roster.Default is ONE answer to how many
institutions this process holds state for, so the per-tenant bounds multiply
to a figure an operator can hold against a pod.
Closing R5 (cross-tenant model eviction) and R5b (the adopted reload was read
once per TRANSACTION under thrash, now once per admission) from the review,
plus the same two shapes in pkg/velocity, which nothing had reported.
Co-authored-by: Hanzo Dev <[email protected]>
Two defects in the same value.
TIES. A decision score is 1 - prod(1-weight) over a fixed set of rule
weights, rounded to four places, so the score alphabet is a handful of
atoms and the modal atom is exactly zero. pava ran over individual
samples, and a run of unproductive-then-productive at one score is
already non-decreasing — so nothing merged, every sample stayed its own
block, and the dedup that followed kept the MAX of two blocks sharing a
centroid. Every plateau holding a positive was reported as certainty, at
the top of the range where declines happen. Ties are now pooled into one
block per distinct score before the algorithm starts, which is what makes
the answer isotonic regression; a rounding collision between two
centroids pools rather than picks.
THE SHAPE GATE. P(score, shape) let the caller supply the value being
compared, and every internal call site supplied m.Shape — so the
training-serving skew control was trivially satisfied everywhere except
the one place a live shape was threaded in. Reading is now impossible
without Under(shape), which returns a Reader: the gate is at the bind,
where the current shape is a fact the server observed, and every
consumer downstream holds a value for which the question is already
answered and has no shape to pass. Reliability draws no chart for a
reader that cannot answer.
Co-authored-by: Hanzo Dev <[email protected]>
Two arithmetic facts the money path was relying on and neither was true.
A stated price is multiplied by a COUNT and summed in int64. At the volumes
this plane measures, a price above about ninety-two thousand units wraps —
and a wrapped cost does not surface as an error. It surfaces as the minimum
of the sweep, which is returned as the threshold to adopt. policy refuses
such a price at the door, where the caller-supplied number arrives, and
evaluate refuses it again in the arithmetic rather than trusting it was
called correctly: a policy sealed before the bound existed reads back
through the same path. The cost then goes ABSENT with a refusal, and the
rank statistics, which never touch a price, are unaffected.
Curve returned one point per distinct score with no bound — fifty thousand
points on a full history, a megabyte no browser plots. The sweep still runs
over every distinct score and the metrics behind it are unchanged; what is
RETURNED is an evenly spaced subsequence keeping both ends, so the shape,
the extremes and the monotonicity survive. Truncating would have cut the
curve off at one end and described a model that stops. It is the argument
Listed already makes about Moved, applied to the other rendering.
Both are mutation-proven: reintroduce the wrap or drop the sampling and the
test that names it goes red.
Co-authored-by: Hanzo Dev <[email protected]>
A vocabulary entry nothing can reach is a branch a consumer writes and
never runs — the same defect the unreachable-direction rule already
refuses. The decide path consults the model and the tenant's rules; it
does not consult controls, and a list is data a rule reads rather than a
separate authority. So the sources are two, and OfRule replaces the
four-way Explicit.
Co-authored-by: Hanzo Dev <[email protected]>
Four packages, each one concept, all base-free and linkable from the
decision path:
reason the closed vocabulary an adverse decision is defended with,
DERIVED from anomaly.Inventory rather than declared beside it,
so it cannot describe a model that is not there. Ranked by the
model's own counterfactual share, never a surrogate.
calibrate isotonic (pool-adjacent-violators, exact) and Platt, turning a
density into a probability. Every map records the model shape it
was fitted under and REFUSES to answer under another, which is
the training-serving skew control: a calibration cannot outlive
the coordinates it describes.
policy the ladder from probability to action, as an immutable value
with a digest over what it DECIDES. Action names stay opaque
here so the vocabulary lives at the wire, in one place.
evaluate the imbalanced-data metrics — ROC-AUC by midranks, average
precision by the step definition, prevalence always beside
both, and a cost in exact int64 nano-units with the
cost-minimising threshold. Plus the deterministic replay of a
candidate over recorded history, and a two-arm learning curve
over a FIXED later window so two steps are comparable.
Accuracy is not computed. On a one-in-a-thousand stream it is not a weak
metric, it is a misleading one.
Co-authored-by: Hanzo Dev <[email protected]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
`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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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 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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>