manifest

package
v1.801.413 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 4, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

This file is HAND-AUTHORED. It is the SOURCE OF TRUTH for the fleet.

Apps is every subsystem that ships as its own binary, in the order the host loads them. That order is NOT what decides which app a path reaches: the router resolves nested static prefixes by SPECIFICITY, and mount order breaks ties only between EQUAL patterns (ai before zen, at the tail). Four facts per app and no more — name, the absolute paths it answers, whether it must already be running when the first request arrives, and whether the host should take traffic at all without it — because that is the whole of what the light host needs to know (cmd/cloud links this package and zip and NOTHING else). What an app DOES lives in the app's own binary (plugin/<name>/main.go), which states its Mount/Shutdown/OwnsHealth/Price once, where they are used.

The fourth fact is Vital, and it earns its place here rather than in the app because readiness is the HOST's answer: the host is what a probe reaches, what a Service routes to, and the only process that can see one child missing while the other 111 serve. See App.Vital — exactly one row sets it.

This list was the composition root once removed (apps.Wire()); that root is gone. Editing an app is now two coordinated edits with no generator between them: a row HERE (the host's view) and plugin/<name>/main.go (the app's view). plugin/gen-app-cmds reads THIS list to scaffold a new app's main and to VALIDATE that the two never drift — every row has a plugin/<name> serving exactly it, and no plugin/<name> app-binary is missing from this list. manifest/order_test.go freezes the sequence so a reorder is a decision, never an accident — but what a reorder can actually change is narrow, and this header used to overstate it: it claimed account's /v1/commerce/topup/wallet "must precede" commerce's /v1/commerce. Measured, it does not — registering commerce FIRST still delivers /v1/commerce/topup/wallet to account, because the deeper prefix is the more specific one. Which app answers a path is pinned by TestEveryServedPathReachesTheAppThatServesIt (manifest/router_test.go), which asks the real router built from these rows; the freeze guards the sequence, not the routing.

account no longer names anything under /v1/iam. It used to — deprecated key aliases and an onboard handler sitting inside IAM's prefix — and those were unreachable in production, because api.hanzo.ai routes /v1/iam/* to IAM. Since iam is GRAFTED rather than relayed through a wildcard, a duplicate address is refused at compose time instead of being decided by registration order.

Package manifest is what the light host knows about the fleet: for every subsystem that ships as its own binary, its name, the absolute paths it answers, and whether it must already be running when the first request arrives.

It deliberately imports NOTHING but zip. That is the whole point — cmd/cloud links this package and zip and stops, so adding the 70th subsystem does not grow the host's build by one package. What an app DOES belongs to the app's own binary; where it lives and what it answers is all the router needs.

apps.go is the single source of truth for both facts (Wire() for the set and its order, the `eager` map for the rest), and apps.go is where a change is made. Apps in apps.go is generated from it — see plugin/gen-app-cmds.

Index

Constants

View Source
const (
	// MCPPath is the fleet's one agent door: POST /v1/mcp, on the host that
	// fronts api.hanzo.ai. /v1/<thing> is the house address rule (never /api/),
	// and being under /v1/ is also what puts it inside the console's API
	// namespace, where an unmatched sibling is a real 404 and never HTML.
	//
	// It is deliberately NOT /v1/ai/mcp. The door serves the UNION of every
	// mounted subsystem's build-time catalogue — o11y's tools, iam's, commerce's
	// — so scoping it under one subsystem's prefix would either shrink it to that
	// subsystem or file a fleet-wide surface under a name that does not own it.
	// One fleet, one door.
	MCPPath = "/v1/mcp"

	// FrameworkMCPPath is zip's built-in default — where a plugin serves its OWN
	// door and, therefore, where a host FORWARDS a composed tools/call (zip's
	// Plugin.mcpPath). cloud.Serve leaves it alone on purpose: a child's door is
	// an internal address on a private ZAP socket, not an edge address, and
	// moving it would break the host's forward.
	//
	// On the HOST this path is claimed by nobody, which is exactly why the front
	// door has to name it: an agent that guessed the framework default must be
	// sent to MCPPath, not handed console HTML.
	FrameworkMCPPath = "/mcp"
)

The fleet's agent door, as ONE fact — for the same reason PrefixesFor exists.

MCP is not a subsystem and not a service: it is the THIRD projection of the same typed-op registry that yields the REST route and the OpenAPI operation (zip installs it in prepare(), from a.registry). So there is nothing to mount and nothing to write down except WHERE the projection answers — and that address was being written down twice, in two files, with nothing making them agree:

cmd/cloud/main.go   zip.MCPConfig{Path: "/v1/mcp"}   <- the host, a literal
webui/console.go    apiPrefixes{...}                 <- did not list it at all

The consequence is the failure this file exists to make unrepresentable. The host MOVED zip's door off the framework default, so on the host NOTHING claims FrameworkMCPPath — and because the console's terminal catch-all did not know that path was a machine door, it answered it with the SPA shell:

GET  https://api.hanzo.ai/mcp  ->  200 text/html  <!DOCTYPE html>

An MCP client checking a status code reads that as a healthy door. It is a green surface over a mechanism that was never there. Registration ORDER is not what did it — the zap-proto/fiber fork matches by specificity, so a static /mcp beats the /* catch-all no matter which registered first; the path simply had no route, in this process, at all.

Both halves now read these two constants, so the address the app SERVES and the address the front door REFUSES TO ANSWER WITH HTML cannot drift.

View Source
const Plugins = "CLOUD_PLUGINS"

Plugins names a release index: hanzoai/ci's binaries.json, on S3 or a GitHub release. Set it and a host ships with no plugin binaries at all — every subsystem arrives over the network on first use.

Variables

View Source
var Apps = []App{}/* 120 elements not displayed */

Functions

func Elsewhere added in v1.801.360

func Elsewhere(name string) []string

Elsewhere returns the prefixes the fleet delivers to somebody OTHER than name.

It exists for the one app whose prefix is not a namespace but a REMAINDER. ai's row is `/v1` and it is mounted last, so what it actually answers is "everything under /v1 that no earlier app claimed" — 15 of the routes its own router registers are delivered to a sibling instead, and four of them (/v1/metrics, /v1/index, /v1/admin/providers, /v1/scrape/preview) answer 404 on api.hanzo.ai today because the sibling that receives them does not serve them.

A door that published those would publish phantoms: paths in the contract that no request reaches, which is precisely the defect a router-derived document exists to make impossible. So the door asks which of its routes are not its own, and the answer comes from Apps — the one routing table — rather than from a second list beside it.

EARLIER ROWS ONLY, because that is how the host routes: it loads the apps in this order and the router takes the first prefix that matches, which is why account's /v1/iam/keys must precede iam's /v1/iam. An app later in the list cannot take a path from an earlier one, so it is not "elsewhere" for it. Coresident apps are skipped for the reason they are skipped everywhere: they claim no prefix.

This is a derivation of Apps, not a second router. manifest/router_test.go builds the REAL router from the same table and asks it where each published path goes, so a wrong derivation here does not ship — it turns that gate red.

func Names added in v1.801.350

func Names() []string

Names is every app, in mount order — which is the fleet's routing order and therefore the order its document is woven in, so a conflict is reported as the router would meet it.

It exists so that "the fleet, as a list of names" is written once. Both callers are about the published document (the host that serves it, the gate that writes it) and a second loop over Apps in either would be a second answer to a question with one.

func OwnerOf added in v1.801.381

func OwnerOf(path string) string

OwnerOf reports which app the host routes a path to: the app whose declared prefix is the LONGEST match. That is the same rule the router itself applies, and stating it here lets anything downstream ask "whose surface is this?" without restating the routing table — the mistake PrefixesFor exists to avoid.

It matters because prefixes nest. `provisioning` is routed /v1/vector and /v1/search, while `product` is routed the more specific /v1/vector/collections and /v1/search/indexes — so a shorter prefix from a different app can swallow a path it does not actually serve. Anything deciding policy from a bare HasPrefix scan will attribute those paths to the wrong app.

An unrouted path returns "" — the caller decides what that means.

func PrefixesFor added in v1.801.350

func PrefixesFor(name string) []string

PrefixesFor returns the paths app answers, as declared in Apps.

WHY THIS EXISTS. "Which paths does this app answer" is ONE fact, and it was being written down twice: here, where the light host reads it to route, and again as a literal in plugin/<app>/main.go, where the app states its own surface. Nothing made the two agree. They happened to agree — I checked all four that restated them — but "happened to" is the whole problem: the copies are in different files, edited by different changes, and a disagreement is invisible until a customer's request 404s.

That is exactly how inference went down on v1.801.318/.319: ai's manifest row said "/v1/ai" while its router served /v1/chat/completions and /v1/models at top level. Both halves were locally sensible; only the pair was wrong, and nothing was looking at the pair.

So the host's list is THE list, and an app reads it rather than restating it. One fact, one place, and the drift is not merely detected but unrepresentable.

An unknown name returns nil, which zip treats as "no prefix claimed" — a plugin whose name does not appear in Apps was never routable anyway, and TestEveryPluginNameIsInTheManifest keeps that from happening silently.

Types

type App

type App struct {
	// Name identifies the app in logs, names its socket, and is the stem of
	// both its env overrides and its sibling binary.
	Name string

	// Prefixes are the absolute paths it answers. zip mounts each path AND its
	// subtree, and the router takes the first match — so a shallower prefix
	// registered earlier wins, exactly as it does when everything is linked into
	// one binary.
	Prefixes []string

	// Coresident means the app is NOT prefix-routed: it mounts as middleware on
	// another app's router and decides per request whether to serve or call Next.
	// The light host must not Load it, because Load's whole job is to claim a
	// prefix and hand matching requests to a process — a contract a middleware
	// cannot express, since a proxied request never falls through to the next
	// candidate.
	//
	// The row still exists, because every plugin/<name> binary needs one (the
	// gen-app-cmds bijection). What it must not do is state a Prefix it does not
	// route: zen said "/v1", the same prefix ai serves, so the manifest carried a
	// duplicate claim that only worked because nothing checked. zip now refuses
	// two owners for one prefix at compose time, which is how this surfaced.
	// Naming the property is the fix; tolerating the duplicate would have been a
	// second way to say one thing.
	Coresident bool

	// Eager starts the child WITH the host instead of on the first request
	// reaching one of its prefixes. It is for a subsystem whose work is not
	// request-driven — one that owns a listener or a background loop, where
	// deferring the start means it silently does nothing and the symptom is an
	// empty dashboard rather than an error.
	Eager bool

	// Required means the HOST must not serve without this app. A required app
	// that will not start aborts the process; every other app degrades to being
	// absent — its prefixes answer 503 and the rest of the fleet serves.
	//
	// It is deliberately a property of the app rather than of start order,
	// because start order is where it lived by accident and that cost a 25-minute
	// outage of the whole API: pubsub is Apps[0] and Eager, so when its child
	// could not open a store, the single `return err` in the host's mount loop
	// took down the API, IAM validation, billing and the team backend with it.
	// Being first in a list is not a claim on everyone else's availability.
	//
	// The default is false, and NOTHING in Apps sets it — see
	// manifest/required_test.go for the argument and for what would justify an
	// entry. The host is a router: it opens no store, validates no token, and
	// holds no state whose absence corrupts anything. Every child enforces its
	// own auth in its own process, so one child's absence cannot silently weaken
	// another's plane — the planes ARE processes. Against that, aborting buys
	// exactly one thing (a pod that never goes Ready) and destroys the console,
	// the health surface, the log stream an operator needs, and every healthy
	// sibling. CrashLoopBackOff is the state in which a process cannot tell you
	// why it is unhappy.
	Required bool

	// Vital means the host is not fit to RECEIVE TRAFFIC without this app: its
	// absence is reported on /readyz as a 503, so Kubernetes takes the pod out of
	// the Service and a rollout that breaks it stalls against the old pods instead
	// of replacing them.
	//
	// It is the other half of Required, and the two are deliberately separate
	// because they answer different questions. Required asks "may this process
	// run at all", and the answer is argued above: aborting destroys the console,
	// the health surface, the log stream and every healthy sibling, so nothing
	// sets it. Vital asks "should this process be sent requests", and the pod that
	// answers no is still up, still serving its siblings, and still able to say
	// why. Required's own doc names "a pod that never goes Ready" as the one thing
	// aborting buys; Vital buys exactly that and nothing else.
	//
	// The bar is NOT "serving without it is unsafe" — that is Required's bar. It
	// is "serving without it is pointless": traffic that arrives will not be
	// answered, so routing it here helps nobody. That is a strictly narrower claim
	// and it is why `ai` qualifies where the credz broker does not.
	//
	// Written against 2026-08-01, ~30 minutes of api.hanzo.ai/v1/models and
	// /v1/chat/completions answering 503 {"error":"mount /v1: no instance
	// running"} while the pod stayed Ready with 0 restarts. o11y v1.5.41 seized
	// :4317-:4319 from the `ai` child, the child's listen failed, and mount()
	// correctly degraded it to absent — but absence went into a map that only
	// /healthz reported, in a FIELD, and the probe reads the STATUS CODE. Every
	// specifically-mounted prefix (/v1/sentry, /v1/o11y, /v1/commerce/tenant,
	// /v1/admin/*) kept answering from its own subsystem, so only a path falling
	// THROUGH to `ai` showed it. A health check that returns 200 while the entire
	// product API is absent is not a health check.
	Vital bool
}

App is one mountable subsystem.

func (App) Plugin

func (a App) Plugin() zip.Plugin

Plugin says where this app's binary is, without naming it twice:

CLOUD_<NAME>_ADDR — already listening there; start nothing, just mount it.
CLOUD_<NAME>_BIN  — the binary's path on disk.
neither           — a file named <name> beside the running host,
                    else the release index at CLOUD_PLUGINS (see release.go),
                    which is how a host with NO plugins in its image runs.

The default is the shipped container layout — one directory, the host plus its per-app plugins, no configuration. Resolving from os.Executable rather than $PATH means a host always loads the binaries it was built and shipped with, not whichever ones a PATH finds.

There is ONE way a name resolves to a binary: its own. A subsystem is its own plugin/<name> binary, on disk beside the host or fetched by digest from the release index — the two link modes of one contract (a developer builds the single lean plugin they are editing; a release ships every per-app binary and the host falls through to the index). A dedicated binary present on disk is someone's explicit intent, so it wins over the index.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL