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:

  • /zap replays the /v1 handlers over the ZAP transport.
  • The console renders them.
  • GET /v1/openapi.json describes 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 a Location header 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 In and never sees the bytes the signature was computed over, so typing these would break their auth.
  • 2 202 Accepted creators. cloud.Created gives 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