The router is the registry
Hanzo Cloud has no checked-in OpenAPI file. The live route table is the source, projected three ways, and a bijection test fails the build if any projection drifts. 1,362 operations across 963 paths, generated per process at request time.
There is no openapi.yaml in the Hanzo Cloud repository. There is no second
registry listing endpoints, no annotation file, and no generator step in CI that
writes a spec to disk. If you want to know what the API is, you ask a running
process, and it tells you by reading its own router.
This post is about why that ended up being the only arrangement that holds, and about the parts of it that do not work — which turn out to be the more useful half.
One table, three projections
serve.go composes one route table. Three things then read it, all of them
after MountAll so that each sees a complete table:
/zapreplays the/v1handlers over the ZAP transport.- The console renders them.
GET /v1/openapi.jsondescribes them.
None of the three holds a copy of anything. That is the entire design constraint, and everything else follows from it. A projection that held its own list of endpoints would be a second source of truth, and a second source of truth drifts — not sometimes, always, on a schedule set by how often someone forgets.
The describe projection is the one people ask about, so: openapi.Live(app) calls
app.Fiber().GetRoutes(true), and every other function in openapi/ is a pure
function of the []Route that comes back. The document is not built from the
router. The document is the router, in a different shape.
Why reading the live router is the only total source
The obvious alternative is to scan the source. It does not work, and the reason is worth stating precisely, because it is the reason a lot of API tooling quietly under-reports.
POST /v1/kms/auth/login is registered like this:
Group("/v1/kms/auth").Post("/login")
No grep finds that path. The string /v1/kms/auth/login does not exist in the
codebase. It exists only in the assembled router, at runtime, after the group
prefix and the leaf have been concatenated by the framework.
Then it gets worse, in a way that is actually correct. The route set is a function
of deployment configuration — cfg.Enabled, plus internal gates like KMS's
if kc != nil. A deployment that does not mount admin does not serve admin
routes. So the honest specification for that deployment does not advertise them.
This is why the document is generated per process, at request time, rather than built once in CI. A spec built in CI describes a hypothetical maximal deployment that may not be the one you are talking to. A spec read from the live router describes the process that answered you.
The test that makes it true
An architecture where the spec cannot drift is a claim, and claims need a gate.
Ours is a bijection, in cmd/cloud/openapi_test.go:
func TestSpecDoesNotDriftFromLiveRouter(t *testing.T) {
It mounts every subsystem in apps.Wire(), generates the document, and asserts
the mapping both ways: every live route appears as an operation, and every
operation is backed by a live route. Not a subset in either direction.
Run today:
bijection holds over 1362 operations / 963 paths / 138 products
It is the only test in the repository whose failure means the published document is lying. Everything else that breaks makes the product wrong; this one makes the contract wrong, which is worse, because integrators have already built against it.
The count moving is not a problem — it moves whenever a subsystem is added. The number that matters is the difference between the two sides, and that number is zero or the build fails.
The product axis is mechanical
The first path segment after /v1/ is the product. openapi.Product reads it and
tags each operation with it, which is what lets a CLI construct
hanzo <product> <resource> <verb> without anyone exercising judgment about
where a command should live.
It is deliberately not the subsystem name. clients/billing also serves
/v1/finance/*, and the URL is what a caller sees, so the URL wins. Naming the
axis after the internal package would have made the CLI a curated artifact — a
thing with opinions in it, that someone has to maintain — instead of a projection.
What the router cannot tell you
Here is the limit, and we do not intend to work around it.
Method, path, path parameters, and product are derivable from the router. Request
and response schemas, query and header parameters, status codes, and auth are
not. The router holds a func(*zip.Ctx) error. The request type is a local
variable inside the handler body:
var req secretPutRequest
json.Unmarshal(ctx.Body(), &req)
Go cannot reflect from a function value into its body. There is no clever fix here, and attempting one in the generator would mean guessing — which is how a specification acquires confident, wrong entries.
The one path to schemas is a typed op:
zip.Get[In, Out](...)
In and Out are on the registration, so the schema is available without
reading anyone's mind. And because they are on the registration, the same entry
also yields an MCP tool — zip/openapi.go and zip/mcp.go are two readers of
one registry. That is the fourth projection, and it arrived without a fourth
mechanism.
The migration is incremental by construction: GetRoutes() is a superset of
app.ops, so converting a handler to a typed op adds schema without changing
anything upstream of it.
The routes that cannot be typed, and why
clients/integrations is converted as far as the wire allows: 20 of 41 routes
are typed ops. The other 21 stay raw handlers, and each one is listed in a
rawRoutes map in ops_projection_test.go with the reason it cannot move:
- 13 browser redirects. The OAuth callback and every link leg answer
302. A typed op returns a value, not aLocationheader and a status in the 300s. - 6 webhooks. Slack and GitHub HMAC, Discord Ed25519, Teams Bot Framework
JWT, Telegram's secret-token header. Every one authenticates a signature over
the raw request bytes. A typed op receives a decoded
Inand never sees the bytes the signature was computed over, so typing these would break their auth. - 2
202 Acceptedcreators.cloud.Createdgives us a 201 seam. There is no 202 seam. Two routes need one, so two routes wait.
We think that list is the most valuable artifact in the whole system. It is an enumerated, test-pinned inventory of exactly where the abstraction stops, with a reason per entry that a reviewer can argue with. The failure mode it prevents is the one where a route silently slips back to a raw handler and vanishes from the document, the MCP tool set, and the CLI at once — invisibly, because nothing was asserting it had been there.
An architecture is not the parts that generalize. It is the parts that generalize plus an honest, enforced account of the parts that do not.
What this buys
A caller integrating against Hanzo Cloud reads a document that was produced by the
process answering their requests, in the deployment they are actually talking to,
a few milliseconds ago. An agent calling over MCP reaches the same handler, with
the same tenancy check, as a REST caller — an anonymous /mcp call gets the same
403 that REST gives, because it is the same code path and a test pins it.
None of that required anyone to remember to regenerate anything.
The general form: if two artifacts must agree, do not generate one from the other and hope. Make one of them a view of the other, and write the test that fails when they are not.
Read more
Cloud-Native Commerce: Why Architecture Matters
How we built Crowdstart as a cloud-native platform from day one, and why this matters for commerce.
Introducing Enso — one API, routed to the best frontier intelligence
Enso is Hanzo's learned router: no single frontier model leads every benchmark, so Enso routes each request to the model most likely to win it — and learns from your feedback as it goes. Microsecond routing on a CPU, a transparent per-request meter, and a 1% fee that pays for itself. Here is how it works, the measured results, and the cost-transparency angle a monolith cannot offer.
Killing Raft: from ZooKeeper consensus to Lux Quasar, natively
How Hanzo Datastore replaces the NuRaft engine under ClickHouse Keeper with Lux Quasar — post-quantum, leaderless consensus linked directly into the C++ server — while keeping the ZooKeeper API that replication speaks.