hip-0106

HIP-106: The Hanzo Plugin Contract. Status Active. Hanzo's own standard — read this before implementing against it.

HIP-0106: The Hanzo Plugin Contract

Abstract

This is the contract a git repository satisfies in order to build and run as a Hanzo plugin: what it may import, what its main does, how it declares the paths it answers, what documents it must project, and what gates prove all of it. Any repository that satisfies it composes into any host — hanzoai/cloud is the reference host — and also runs standalone as an ordinary HIP-0119 service, with no second code path and no build matrix.

A plugin imports zip and nothing of the host's. Its binary is composed at run time, not linked at build time, so the host's build does not grow when a plugin does, plugins build in parallel, and one changing rebuilds only itself. Capabilities expand strictly opt-in: a language runtime, a store, telemetry, the call plane — each is an import the plugin chooses or a value that arrives at run time, and a plugin needing none pays for none. The smallest host binary runs just the smallest set.

A plugin DECLARES the paths it answers; the host DISCOVERS them. A host that hand-maintains a routing row for a repository it does not build will get that row wrong, and a wrong row is an outage, not a documentation defect (§3.1 records the one that happened). The declaration is projected from the plugin's own live router, travels as an artifact verified by the same digest as the binary, and is gated by asking the composed ROUTER where each declared path actually goes — never by consulting a committed document.

Every app is a plugin, uniformly, and a plugin is an ordinary repository. The correspondence between apps and plugins is one-to-one and gate-enforced (§9); a plugin runs standalone as readily as it composes (§10); an optional or paid capability is a separate artifact the host loads at run time, never a build tag in public source (§11); a host enumerates its composition from the plugins' own live declarations rather than a committed catalogue (§12); each app declares its customer-facing identity as named Go fields, because a field with no Go name is silently deleted by two closed-struct round-trips on the way to the published document (§13); and a third party can fork a plugin, extend it and contribute it back using only public repositories (§14).

Supersedes this HIP's own earlier process model. The fused binary this document originally specified — one link that imported every subsystem as a Go package, driven by cloud.Register + apps.Wire() — is deleted. cmd/cloud is now a light host that links zip, a manifest and a console embed (402 packages) and composes ~116 per-plugin binaries as child processes. The subsystem-boundary, extension-surface, multi-language, inter-subsystem-call, single-process and migration sections that described the old model are replaced by the Specification below. Sections on Commerce's PCI scope, the solo-vault CDE and PSP optionality are retained unchanged; they are about commerce, not about the binary, and are filed here only by history.

This document does not restate HIP-0119. HIP-0119 is the one and only shape of a Hanzo backend service — listeners, ports, health paths, environment names, image naming, deployment. A plugin conforms to it in full and this document adds only what it does not cover. Restating it here would create the second copy HIP-0119 §10 forbids.

Wire. Every inter-plugin call is ZAP: typed ops over a unix socket, JSON only at the system edge. No gRPC and no protobuf in Hanzo-authored code; where an external standard must be spoken at an interop boundary (OTLP, OpAMP), the conversion lives in the zap tooling and never in a service.

Motivation

Two forces, pulling the same way.

The build. Measured 2026-07-29: hanzoai/cloud's root package is 574 packages, and plugin/crm — whose own code is about two packages — is 576, because it imports that root to reach cloud.Serve. A conforming plugin built against zip alone is 260. The 316-package difference is not crm's code; it is the host's composition root, arriving as a library. Multiply by 116 plugins and that is the whole cost of the fleet's build. It cannot be fixed inside cloud, because the coupling is the import.

The repository boundary. A capability that belongs to another team, another licence, or another release cadence has to live in another repository. Today it cannot: to be mountable it must implement a MountFunc from cloud's root package, so every plugin is inside cloud by construction, and the OSS/private split, per-capability release cadence and third-party extension are all blocked on the same import.

Both are removed by the same move: state the contract in terms of zip, make the plugin declare its own routing, and let the host discover instead of transcribe.

Specification

The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119. "Plugin" below means a git repository whose build produces a binary a host composes at run time. "Host" means the process that composes it — hanzoai/cloud's cmd/cloud is the reference host, and there is nothing cloud-specific in this contract.

This document does not restate HIP-0119. HIP-0119 is the one and only shape of a Hanzo backend service: two listeners, /v1/ only, /healthz + /readyz + /metrics on the ops port, environment configuration, <org>-<app> client IDs, one CR per service, and the §9 forbidden list. A plugin binary run standalone IS a service and MUST conform to HIP-0119 unchanged. What follows is only what HIP-0119 does not say: what a plugin repository may import, what its main does, how it declares the paths it answers, what projections it must produce, and what gates prove it. Where the two touch, HIP-0119 is authoritative and this document cites it.

Every number below was measured on 2026-07-29 against hanzoai/cloud at e5874218 and github.com/zap-proto/zip@v1.18.7. Re-measure before quoting one; the command is given each time.

§1 The module contract

§1.1 What a plugin imports

A plugin's non-test import graph MUST contain, from first-party code, exactly:

| Import | Role | Status | |---|---|---| | github.com/zap-proto/zip | the framework: router, typed ops, transports, caller, call plane | required | | github.com/zap-proto/zip/middleware | Recover, RequestID, Logger, Telemetry, RateLimit, … | optional | | github.com/hanzoai/plane | the internal call contract: op-name constants and their In/Out types | only if it calls or answers a peer | | github.com/hanzoai/money | exact decimal amounts | only if it handles money | | one zip/<lang> adaptor | a non-Go handler runtime (§5) | only if it mounts one |

It MUST NOT import github.com/hanzoai/cloud, any hanzoai/cloud/... subpackage, or any other host package. That is the entire rule, and it is checked by a gate, not by review (§8.1).

Everything else a plugin needs — a store driver, an HTTP client, a PDF library — is the plugin's own dependency, chosen by the plugin, paid for by the plugin, and invisible to every other plugin and to the host.

§1.2 The measured floor

$ go list -deps . | wc -l

| Binary | Packages | Of which | |---|---|---| | zip alone, clean module, v1.18.7 | 258 | 0 goja, 0 esbuild | | zip alone, clean module, v1.18.6 | 315 | 33 goja + esbuild | | a conforming plugin: zip + money + 2 typed ops + tenancy refusal | 260 | — | | cmd/cloud (the host, zero app imports) | 402 | — | | hanzoai/cloud root package | 574 | still imports 10 apps/* packages | | plugin/crm (today's shape — imports cloud) | 576 | its own code is ~2 packages | | hanzoai/cloud/plane (already a leaf) | 74 | stdlib + hanzoai/money |

The 316-package difference between a conforming plugin (260) and today's plugin/crm (576) is the whole of what §1.3 removes. zip v1.18.7 cutting zip/runtime out of the root package is what makes 258 the floor rather than 315; a plugin MUST require zip >= v1.18.7 so that a JavaScript interpreter and a bundler are not linked into a binary that has no JavaScript.

§1.3 The six gaps, and where each one goes

plane/ is the model. It is a LEAF: op-name constants plus the In/Out types those ops carry, importing nothing of the host's, which is precisely what lets an aggregator call the ledger without linking it. Every gap below is resolved by one of three placements, in this order of preference:

  1. In zip — when it is a property of any typed op on any transport.
  2. In one tiny published contract module — when it is a name both ends must

spell identically and neither owns.

  1. At run time — when it is a credential, a key, a sink or a policy. A value

that arrives at run time cannot be a compile-time dependency, and a plugin that receives it holds no code to keep in sync.

Nothing is resolved by a fourth placement. In particular nothing is resolved by a build tag, and nothing is resolved by a stub: where a capability may be absent, the constructor MUST exist either way and MUST be nil-safe, so no caller ever branches on availability (middleware.Telemetry(nil) returning a pass-through handler is the shape).

(a) The plane contract — a contract module, and one function in zip.

github.com/hanzoai/plane is hanzoai/cloud/plane extracted verbatim to its own module: 74 packages, no host imports, already correct. The extraction is a module-path change and nothing else.

The client and server halves are already zip and MUST NOT be duplicated. Every helper the host currently wraps them in is deleted in the change that names its survivor:

| Deleted | Survivor | |---|---| | cloud.Plane() | zip.New(zip.Config{AppName: name}) — a second app, listening only on zip.SocketPath(name), never mounted on the edge | | cloud.ServePlane(name, log) | app.Listen(zip.SocketPath(name)) | | cloud.Peer(app) | zip.DialApp(name) | | cloud.Who(ctx) | zip.CallerOf(ctx) | | cloud.For(ctx, org) | zip.WithCaller(ctx, zip.Caller{Org: org}) | | cloud.As(c, org) | zip.Delegate(c *Ctx, org string) context.Context — MOVE to zip; it reads only Ctx accessors | | cloud.bindRuntimeDir | the host sets ZIP_RUNTIME_DIR on its children; zip.RuntimeDir() already resolves $ZIP_RUNTIME_DIR$XDG_RUNTIME_DIR/zip/run/zip | | cloud.Ask[In,Out] | zip.AskIn,Out — ADD to zip: dial, call, close |

zip.Ask is the only genuinely missing piece. zip has DialApp and Call; it does not have the one-shot, so every caller writes the same six lines. One function, in zip, because "dial a peer by name and invoke one op" is a property of the call plane and not of any deployment.

(b) Identity and the tenant read — in zip, and the naive rule is WRONG.

zip.CallerOf(ctx).Org != "" is NOT a sufficient tenancy check, and the host's own code already knows why. apps/principal.OrgOf refuses on three conditions, not one:

func OrgOf(user, org string) (string, bool) {
    if strings.TrimSpace(user) == "" { return "", false } // no validated principal
    org = strings.TrimSpace(org)
    if org == "" || len(org) > MaxOrgLen { return "", false }
    return strings.Clone(org), true
}

X-Org-Id on its own is a client-settable header. Without the validated user claim beside it, "the org is non-empty" is a statement the caller made about itself. And an unbounded org is retained past the request as a store key and a ledger key, so a length bound is not hygiene.

So zip MUST carry that decision, once, as

// Tenant is the tenant a call may act for, or ("", false).
func Tenant(ctx context.Context) (string, bool)

reading CallerOf(ctx) and applying exactly OrgOf's rule. apps/principal's copy is deleted in the same change. A plugin MUST use zip.Tenant and MUST refuse on !ok; a plugin MUST NOT re-derive the rule.

Where the principal COMES FROM is HIP-0134 and is not restated here: IAM establishes it once, nothing re-derives it, and a call carries it as delegation rather than assertion because the socket already bounds who may speak. Two obligations follow for the parties this contract does cover:

inbound external request before proxying it to a child, and attach the principal IAM established. Deleting a forgery is not minting an identity, and a host that skips it hands a client's own X-Org-Id straight to a plugin.

reads the one that arrived, checks it is PRESENT and well-formed with zip.Tenant, and then authorizes — its own rules, in its own process, on the decoded input, which is what HIP-0134 §3 keeps in the plugin and what process isolation actually buys. 116 JWKS clients would be 116 places to get an issuer wrong, and cloud's current per-child validator (cloud.SanitizeIdentity) is a host package no external repo can import.

Checking that a delegated principal is present and bounded is not a second implementation of IAM. It is the precondition for using the value as a store key, and skipping it is how an empty string becomes a tenant.

(c) A store — at run time.

The plugin chooses its own store library and pays for it. The key arrives at run time: the plugin asks the KMS peer for its data-plane key over the plane (plane.KMSGet), and the launch token it presents is an environment variable the host stamped on that one child. Both the token's variable name and the broker exchange are op-name constants in hanzoai/plane; no credential library is imported. A plugin that cannot obtain a key MUST fail closed at its first store open and MUST NOT write plaintext.

(d) Config — nothing is missing.

HIP-0119 §5 already names the shared environment (PORT, OPS_PORT, LOG_LEVEL, BRAND, DOMAIN); zip names ZIP_ADDR and ZIP_RUNTIME_DIR. A plugin reads its own variables with os.Getenv under a <NAME>_ prefix and MUST start with zero required flags. There is no config package to import and none is to be written: a shared config struct is how ~100 host variables became a dependency of every subsystem.

(e) Telemetry — at run time, through a nil-safe seam that already exists.

middleware.Telemetry(sink O11ySink) is the seam, and Telemetry(nil) is a pass-through. A plugin MUST install it and MUST NOT link an OTLP SDK: the collector lives in the o11y binary alone, and spans leave a plugin as ordinary plane calls to o11y's op. This is the direction the OTLZ work is taking fleet-wide; a plugin that links OTLP today adds 43 packages to its floor for a capability the host already provides.

(f) The host's own facilities — not available, by construction.

A plugin does not serve the console, does not thread operator flags, does not scope credentials for other processes, and does not own the ops listener when it runs as a child (zip.Addr("") != "" means a host handed it a socket; the ops port is the host's — HIP-0119 §1 applies to the deployment's listeners, and a child's socket is not one). These are front-door concerns and stay in one process.

§2 The entry point

A plugin main does four things, in this order: construct, register, describe if asked, serve. This is the generalisation of what works today — plugin/o11y/main.go is the only existing main written against zip alone, and the other 115 are cloud.Serve calls, which is exactly the import this contract removes.

// Command billing is the billing service. It builds as its own binary; a host
// composes it at run time.
package main

//go:generate go run github.com/zap-proto/zip/cmd/zipdoc

import (
	"fmt"
	"os"

	"github.com/zap-proto/zip"
	"github.com/zap-proto/zip/middleware"
)

const name = "billing"

func main() {
	if err := run(); err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", name, err)
		os.Exit(1)
	}
}

func run() error {
	app := zip.New(zip.Config{AppName: name})
	app.Use(middleware.Recover(), middleware.RequestID(), middleware.Logger(app.Logger()))

	svc := newService()          // this plugin's own dependencies, built here
	svc.ops(app)                 // typed ops — the ONE registry (§4)
	svc.plane(planeApp(name))    // peer ops, on the second app (§1.3a)

	// Describe instead of serve. Before anything opens a store: a projection is
	// a function of the code, and a describe run must not touch a real store.
	if mode, dest, ok := zip.Described(); ok {
		return zip.Describe(app, mode, dest)
	}

	// zip.Addr: the socket a host handed us, or our own port run directly.
	// This is the whole plugin side of the transport contract.
	return app.Listen(zip.Addr(":9653"))
}

Requirements:

  1. zip.Config.AppName MUST equal the plugin's name, and the name MUST be the

binary's name, the socket's stem (zip.SocketPath(name)), the Declaration.Name (§3) and the <org>-<app> IAM app segment. One name, no mapping table.

  1. The last line MUST be app.Listen(zip.Addr(fallback)). Reading ZIP_ADDR

any other way, or binding a fixed port when a host handed one over, is the failure where every child but the first dies on "address already in use".

  1. Peer ops MUST be registered on a SECOND *zip.App that listens only on

zip.SocketPath(name). A typed op rides every transport its app listens on, so an internal op registered on the edge-facing app is an internal op served on :8000. The separation is structural, not a check.

  1. main MUST NOT read configuration, open a store, or dial a peer before the

describe check.

  1. zip.Config.Eager MUST be set true if and only if the plugin's work is not

request-driven — it owns a listener, a consumer or a background loop. This is the one fact about a plugin that its router cannot show (§3.2).

zip.Described() and zip.Describe(app, mode, dest) replace the host's cloud.SpecRequested / cloud.WriteSpec pair and are the only new API §2 requires. mode is openapi or declare (§3, §4). Both MUST write to a file, never stdout: a plugin's own dependencies write to stdout at construction — zip.New itself logs a line, GORM logs queries, sqlite-vec prints a warning — and > file splices those into the front of the document.

§3 The declaration — the manifest problem, inverted

§3.1 Why a host-authored row cannot survive another repo

manifest.Apps in hanzoai/cloud is a hand-authored table of {Name, Prefixes, Eager} and it is the router: cmd/cloud mounts each row's prefixes through zip.Load and the first match wins. A prefix nobody wrote down is not a documentation gap; it is a 404 or a 405 on live traffic.

The proof is on the record. The analytics row listed the read endpoints and omitted the four ingestion doors apps/analytics/event.go actually serves (/v1/event, /v1/insights/e, /v1/analytics, /v1/analytics/batch). Every product beacon in the fleet therefore fell past every prefix onto the row holding the bare /v1 remainder, which does not serve them, and answered 405 — starting eighteen seconds after the ReplicaSet running the first image in which manifest.Apps was the router. The row had been harmless for as long as each app called its own routes(); the mega-build's death made it load-bearing, and a list that had been documentation became a routing table overnight.

A cross-repo plugin makes that failure permanent rather than occasional. Cloud cannot hand-maintain a prefix row for a repository it does not build: the row and the routes are then edited by different people, in different repos, on different schedules, with no compiler and no test between them.

So the direction MUST invert. The plugin declares; the host discovers.

§3.2 What the binary emits

The binary already describes itself — it emits its own OpenAPI subset from its own live router. It can therefore describe its routing the same way, from the same source. zip gains one type and one method:

// Declaration is what a plugin tells a host: who it is, whether it must be
// running before the first request arrives, every route pattern its router
// holds, and every op name it answers on the call plane.
type Declaration struct {
	Name   string   `json:"name"`
	Eager  bool     `json:"eager,omitempty"`
	Routes []Route  `json:"routes"`
	Ops    []string `json:"ops,omitempty"`
}

// Route is one pattern in the ROUTER's own spelling — ":id", not "{id}".
type Route struct {
	Method  string `json:"method"`
	Pattern string `json:"pattern"`
}

// Declaration projects the live router. Nothing is inferred from the AST, from
// a golden, or from the OpenAPI document.
func (a *App) Declaration() Declaration

Rules:

  1. Routes MUST come from the router (a.Fiber().GetRoutes(true)), sorted by

(pattern, method), deduplicated. It is therefore complete by construction: a route the plugin serves and does not publish is still declared, which is precisely the analytics-ingestion case.

  1. Routes MUST exclude /.well-known/*. Those are zip's own control plane,

per process; the host serves its own.

  1. Ops MUST be every registered OperationID. The op token is the operation's

one identity: the OpenAPI operationId, the MCP tool name, the CLI command and the plane op are the same string (§4).

  1. Eager is Config.Eager. It is the only field not derived from the router.
  2. There is no Prefixes field and no Remainder flag. A plugin that owns a

version remainder declares the catch-all route it actually registered (/v1/*); the host's rule about remainders reads the routes, so the fact lives in one place.

This is implementable today with no change to zip beyond exporting it. Measured:

$ ./demo-plugin declare {"name":"demo","routes":[ {"method":"POST","pattern":"/v1/demo/quote"}, {"method":"GET","pattern":"/v1/demo/quotes/:id"}]}

§3.3 How the declaration travels

A host mounts a plugin's routes before starting it — that is what makes lazy composition possible — so the host MUST NOT have to execute the plugin to learn its routes. The declaration therefore travels as a verified artifact beside the binary:

built the binary, and publishes it beside the binary with its own SHA-256 in binaries.json. The declaration is a function of the bits by construction: same job, same binary, no second source.

<name> at image-build time, from the same rule.

verified index and MUST refuse a plugin whose declaration digest does not verify, exactly as it refuses an unverified binary.

A host repository MAY commit the declarations it composed — cloud does, as it commits plugin/<app>/openapi.json — but only because a gate CI actually runs regenerates them from source and fails on any diff (§8.4). A committed artifact that nothing forces back to source is a golden, and §3.5 exists because a golden was consulted instead of a router.

Updating a plugin is then ONE commit in the host repo that moves {URL, Sum, declaration} together, and the routing change is visible in the diff: the six analytics doors appear or disappear as lines, reviewed, instead of being absent from a table nobody diffed against reality.

§3.4 How the host validates a declaration it did not write

The host MUST reject a declaration that fails any of:

  1. Digest. The declaration verifies against the digest in the index that also

authorized the binary.

  1. Shape. Every Pattern is absolute, contains no .., and begins with a

literal segment. A pattern beginning with a parameter (/:org/:repo) matches every request of that arity in the fleet; in one binary its handler could inspect the Host and fall through, but a request proxied to a child never falls through.

  1. Namespace. Every Pattern is under /v1/ (HIP-0119 §2), or under a root

the host reserves and lists by name (/login/oauth, /.well-known/..., /healthz). No /api/. No /v2.

  1. Remainder. At most one plugin in the composition declares a

version-remainder catch-all (/v1/), and which plugin that is MUST be host configuration rather than a first-come-first-served race. A bare version root claims paths other plugins own and paths that do not exist yet*.

  1. Name. Declaration.Name equals the name the host installed it under, and

is unique in the composition.

  1. Liveness agreement. On a child's first start, the host reads the child's

live declaration from /.well-known/zip/plugin.json and compares it to the one it routed. A mismatch means the mounted routes are not the served routes; the host MUST log it by name and MUST leave that plugin's prefixes answering 503 rather than 404-ing them forever, and MUST keep serving every other plugin. That is the same degrade-not-abort policy a failed start already gets: being first in a list is not a claim on everyone else's availability.

§3.5 The gate: the ROUTER ORACLE over the union, at build time

zip.Load accepts a duplicate claim silently — measured: a second Load of /v1/x by a different plugin returns nil and the first registration keeps answering. So the conflict gate is the host's, and it MUST run at build time.

There is exactly one gate, and it asks the ROUTER:

// For every route every plugin declared, compose the fleet's real router
// through the real zip.Load, send a concrete request, and require the answer to
// be the plugin that declared it.
func TestEveryDeclaredRouteReachesItsPlugin(t *testing.T) { … }

address, so an address that answers with its own NAME turns "where does this request go" into a value the test reads. Only the WIRE is replaced. Load, Mount, the patterns and the first-match are the fleet's own.

substitution can never land on a sibling's static segment and report the wrong owner.

proved nothing") }`. Every way it could examine zero routes — an empty composition, declarations that decoded to nothing — is a defect elsewhere that would otherwise arrive as a green tick.

entry fails just as loudly, because a fix nobody records is a fix nobody can see, and a ledger padded with fixed entries hides the next real one behind a number nobody trusts.

openapi/weave_test.go looked for this exact defect and could not find it: it exempted any path already present in openapi.yaml — the artifact it was protecting — and reported survivors with t.Logf. So it printed nothing and passed while the fleet misrouted 58 published paths. One side of a comparison has to be SOURCE. Here both sides are: the declaration is projected from the plugin's router, and the destination is read from the composed router.

zip.Load SHOULD additionally return an error when a second plugin claims a pattern already claimed. That catches exact duplicates at compose time with a clear message; it cannot catch shadowing (a parameter sibling swallowing another plugin's static path is sometimes legitimate and sometimes an outage), which is why the oracle remains the gate. Two checks, two failures, not two ways to do one thing.

Order stops mattering. Measured: with exact declared patterns, mounting a parameter sibling (/v1/demo/quotes/:id, plugin A) before a deeper static sibling (/v1/demo/quotes/special, plugin B) routes each to its owner, and so does the reverse order — the router's specificity decides, not the mount sequence. manifest/order_test.go's frozen order exists because prefixes were widened ancestors; with declarations it is deleted. Cross-repo composition cannot depend on a total order that no single repo owns.

§3.6 What a declaration cannot express

A capability that must run as MIDDLEWARE on another plugin's router is not a plugin. apps/zen inspects every /v1 request, claims the ones whose model is a zen SKU and calls Next() for the rest; it dispatches on a BODY field, so it can enumerate no paths, and a proxied request never falls through to a next candidate. Such a capability MUST be part of the binary whose router it filters, or MUST become a route that binary forwards to. It MUST NOT be given a declaration, and the bareVersionExempt set that currently carries it is deleted with the prefix table.

§4 The projections — one registry, four documents

A typed op is ONE registry entry:

zip.Post(app, "/v1/billing/invoices", o.create, zip.WithOperationID("billing_invoice_create"))

and it MUST produce all four projections, from that one entry:

| Projection | Producer | Consumer | |---|---|---| | OpenAPI subset | app.OpenAPISpec()<binary> openapi <file> | the woven fleet spec, every generated SDK | | MCP tool | app.MCPTools() | agents | | CLI command | zip.CommandsFromSpec(spec) | hanzo <service> <command> | | Plane op | the OperationID | peers, via zip.Ask |

The OperationID is the same string in all four. An untyped route (app.Get(path, handler)) is a route and NOTHING else: no schema, no tool, no command, no op. Measured in hanzoai/cloud: 414 typed op registrations against 725 untyped route registrations, and 1,427 operations across 1,011 paths in the woven spec — so the majority of the published surface is invisible to three of the four projections. A plugin MUST register every op typed. zip.Ctx handlers are permitted only where the response cannot be a value: a redirect, a stream, a non-JSON body, a second legitimate success code.

§4.1 //go:generate zipdoc is not optional

Go drops doc comments at compile time. Reflection sees types and struct tags, never comments. So a build-time AST pass is the ONLY way an op's prose reaches a document, and a package without the directive can NEVER get prose into the spec, however correct everything else is.

Every package registering a typed op MUST carry, verbatim:

//go:generate go run github.com/zap-proto/zip/cmd/zipdoc

and MUST commit the resulting zipdoc_gen.go (a bare go build does not run generate, so an untracked file is a binary whose document has no descriptions). make test MUST run zipdoc -check so a lift that drifts from its source turns CI red instead of shipping stale prose.

The doc comment is a product surface, not a comment. Measured on a two-op plugin, before and after adding the directive:

before: MCP tool demo_quote description '' OpenAPI description None after: MCP tool demo_quote description 'quote prices one reference.' OpenAPI POST /v1/demo/quote description 'quote prices one reference.'

The same sentence reaches the published API document a customer reads and the tool description an agent reads when choosing whether to call it. Example: and Response: lines in the comment become the spec's examples, because a spec nobody can try is most of an interactive document wasted.

Measured gap in hanzoai/cloud: six packages register typed ops and carry no directive — apps/admin/core, apps/commerce, apps/iam, apps/kms, apps/platform, apps/treasury. Two of them are plane surfaces. Their ops carry WithSummary("…") strings sitting directly under a doc comment that says the same thing: two places to change, one to forget. §8.5 turns that into a failure.

§4.2 Composition, never carving

A plugin's OpenAPI subset MUST be generated from the plugin's OWN live router. It MUST NOT be sliced out of a fleet document by prefix — that makes the fleet the source and the plugin a derivative, which is backwards, and is exactly how a catch-all silently swallows a neighbour's routes. The fleet document is woven upward from the subsets, refusing when two plugins claim one path+method or mean different things by one schema name.

plugin/ingress lost eight paths from every published SDK in every language because the only gate compared two DERIVED artifacts, which agreed with each other while both were wrong.

§5 Language runtimes — opt-in adaptors, in zip

Go is native and built in. Every other language is an ADAPTOR, each in its own package inside the zip module, each owning its own dependencies. A plugin that needs none pays for none.

| Package | Engine | Sandbox | Status | |---|---|---|---| | zip/js | goja + esbuild | soft | exists as zip/runtime; RENAME | | zip/wasm | wazero | hard | MOVE from hanzoai/base/plugins/wasmvm | | zip/py | CPython sub-interpreters | none — single tenant only | MOVE from base/plugins/pyvm | | zip/star | starlark | hard, deterministic, no I/O | MOVE from base/plugins | | zip/v8 | v8go (cgo) | soft | add only when a consumer asks |

The seam is already right and MUST NOT be duplicated: zip.Loader and zip.Module are interfaces declared in root zip, which imports no implementation. A plugin declares the runtime it needs by importing that adaptor and passing its loader:

import "github.com/zap-proto/zip/js"

app := zip.New(zip.Config{AppName: "webhooks", Loader: js.Loader()})
app.Module("POST /v1/webhooks/transform", "goja", "./ext/transform")

The import IS the declaration. There is no runtime flag, no manifest field and no build tag, and Go's module graph pruning makes it free: measured, zip's go.mod still requires goja and esbuild for zip/js, and a plugin that does not import zip/js builds 258 packages with zero goja packages in its graph. One repo, N opt-in packages, each cost borne only by the binary that asks.

hanzoai/base MUST reuse these adaptors and delete base/plugins/{extruntime,gojavm,wasmvm,v8vm}. Base is an application; mounting a foreign-language handler is a framework concern, and today a plugin that wants one route in JavaScript must depend on a whole application framework to get it. app.Module stays the ONE mount verb — there is no ModuleWasm, ModuleGoja or ModulePython, on purpose.

A module route registers a route and no op, so it is in no document, is no MCP tool and is reachable by no peer. That hole closes when an extension DECLARES its contract in its manifest and zip.Module surfaces the schema; until then a route whose contract IS known belongs in a typed op with the module as an implementation detail behind it.

§6 Identity and tenancy — non-negotiable

  1. A plugin MUST NOT read a tenant from caller-supplied input. There MUST be no

Org field in any In type, on any op, on any transport. An org in the argument is an org the caller chose, and a caller that can name the org can bill or read another tenant.

  1. The tenant rides the CALLER: forwarded from the gateway's assertion with

zip.Ctx.Forward (a typed handler's ctx already carries it), or stated once and explicitly by a background job with zip.WithCaller. An inbound request always wins over a stated caller, so a background job can supply an identity where there is none and can never launder one.

  1. The callee reads zip.Tenant(ctx) and MUST refuse !ok — which is stricter

than "refuse empty" for the reason in §1.3(b): a non-empty X-Org-Id with no validated user claim beside it is a client's own assertion. Establishing the principal is IAM's (HIP-0134); checking that one arrived and is bounded before using it as a key is the callee's.

  1. Caller.Admin, Caller.OrgAdmin and Caller.Owner are three distinct

authorities and reading one for another is a privilege escalation. OrgAdmin says a person administers THEIR OWN org; Owner says which org that is; a deployment reserving one org for platform operators gates cross-tenant surfaces on Owner alone.

  1. An org is used VERBATIM — trimmed, never lower-cased, never truncated.

Folding collapses distinct owners ("acme", "ACME", a 32-char prefix) into one bucket, which is itself a cross-tenant break.

  1. Money is an exact decimal with its currency beside it (plane.Money), never a

count of minor units. HUSD carries 18 decimals, so "cents" is not the smallest unit; a per-token charge is routinely finer than one. A malformed amount is an ERROR, never a zero — a gate that read an unparseable charge as "nothing to authorize" would let the work through free.

  1. The wire is ZAP. json tags are the DOCUMENT's vocabulary only. A field IS

its offset, so compatibility is STRUCTURAL: append fields at the end, and only at the end. Reordering, inserting or retyping one changes what every existing peer reads.

  1. A peer socket is 0600 and SO_PEERCRED-authenticated, so a caller on it is

one of our own processes; zip.PeerOf(ctx) is available as a coarse infrastructure gate under the per-user authorization. That is not a trust boundary between our own plugins, and a check that pretends otherwise should say what it is.

§7 CI and release

A plugin repo carries exactly two CI files and no build logic of its own.

hanzo.yml at the root:

binaries:
  - name: billing
    main: .
    platforms: [linux/amd64, linux/arm64]
test:
  - name: go-test
    run: |
      set -e
      export GOPRIVATE='github.com/hanzoai/*' GOWORK=off
      make test

.github/workflows/cicd.yml, unchanged from repo to repo:

name: CI/CD
on:
  push: { branches: [main], tags: ["v*"] }
  pull_request:
  workflow_dispatch:
jobs:
  cicd:
    uses: hanzoai/ci/.github/workflows/build.yml@v1
    secrets: inherit

Registries never mix. ghcr.io/<org> remains only for already-published OSS deps external users pull.

KMS_WORKSPACE variable. Registry, IAM and cluster credentials come from KMS at run time.

that broke it) and publish on a tag, after the test: gate. binaries.json ships beside them with {name, os, arch, url, sha256} for every artifact, plus each artifact's <name>.plugin.json declaration (§3.3).

source, not of the checkout path.

§7.1 A tag is a RECEIPT

In hanzoai/cloud a v* tag is minted only AFTER build + smoke + image push succeed: main push → compute version → build → smoke → push image → tag → notify. Any failure fails BEFORE the tag and leaves no receipt. The inverted, tag-triggers-build order left phantom tags with no image behind them, which is ImagePullBackOff on a version that never existed. Hand-tagging forges a receipt for an image that was never built.

The equivalent rule for a plugin repo:

**A v* tag on a plugin repo asserts: this commit's test: gate passed, its binaries built for every declared platform, its declaration was projected from those exact binaries, and all of it was published with digests. A tag MUST be minted by the pipeline that proved those things, never by a person and never before them.**

Concretely: the release job publishes artifacts + binaries.json + declarations, and only then creates the tag. A host installs unattended by digest, so a tag that does not correspond to published, verified bits is a supply-chain lie — and it is a worse lie for a plugin than for an image, because the host will execute the bits the tag pointed at.

Patch bumps only, from the actual latest tag +1, and never above v1.x.x.

§8 The gates a conforming repo MUST run

Each gate names the failure it catches. All of them run from make test, and hanzo.yml's test: block MUST invoke make test — not a bare go test.

§8.1 plugin-is-a-leaf. go list -deps ./... | grep -E '^github.com/hanzoai/(cloud|base)($|/)' MUST be empty. Catches: one stray import of a host package dragging 316 packages back into the plugin, whose only symptom is a slow build. A grep over source is NOT this gate: the dangerous break is the one that still compiles, so the check is over the import graph a build actually produces.

§8.2 builds-and-mounts-alone. The binary links and, run with no host, mounts its whole surface and reaches listening. Catches: a plugin that only works because something else in a fused binary initialised it first.

§8.3 declaration-current. Regenerate <name>.plugin.json from the built binary; fail on any diff. Additionally assert openapi ⊆ declaration: every path in the OpenAPI subset MUST appear in the declaration. Catches: the analytics-ingestion outage class — a published path the host will not route. The subset relation is one-way: a served-but-unpublished route (a webhook door) MUST still be declared, or it 405s.

§8.4 openapi-current. Regenerate the OpenAPI subset from the binary and fail on any porcelain change. Catches: routes moving without the document moving — eight plugin/ingress paths silently absent from every generated SDK.

§8.5 zipdoc-current. For every directory carrying the directive, run zipdoc -check. Additionally: every directory that registers a typed op MUST carry the directive. Catches: prose that can never reach the spec (six packages in cloud today, §4.1), and prose that has drifted from the comment it was lifted from.

§8.6 router-oracle (host-side, §3.5). Every declared route reaches its declaring plugin. Catches: two plugins claiming one path — at build time, not at first request, because zip.Load accepts the duplicate silently.

§8.7 tenancy. No In type on any op has an Org/Tenant/OrgID field; every op that touches per-tenant state refuses when zip.Tenant returns !ok. Catches: cross-tenant read or bill via a caller-chosen org.

§8.8 the-suite-is-not-empty. go test ./... -json MUST report a NON-ZERO number of executed tests, and every package MUST contribute at least one. Catches: a build tag excluding an entire suite while the command exits 0. hanzoai/gateway reported build constraints exclude all Go files on every tree, healthy or broken, for years — every file under tests/ sat behind //go:build legacy, so the suite had NEVER executed and hid 12 real failures. A repo whose files carry build tags MUST run ./... once per tag set (make test in gateway now runs a tagged and an untagged pass) and MUST assert both passes executed tests.

§8.9 ci-invokes-the-gate. A test MUST assert that hanzo.yml's test: block invokes make test. Catches: the failure that makes every gate above worthless. hanzoai/cloud's CI never called make test at all, so gates added to the Makefile protected nobody; hanzoai/gateway had no workflow, so its make test was a gate in no pipeline. A gate that CI does not invoke is a comment.

Two properties every gate MUST hold, from §3.5 and repeated because they are the two ways a green tick lies:

it examined. Every route to zero examinations is a defect elsewhere that would otherwise arrive as a pass.

compared a document to the document it protected, agreed with itself, and passed through 58 misroutes.

§9 Every app is a plugin, and the bijection is enforced

There is no second kind of app. Every app is a plugin and every plugin is an app, and the correspondence is one-to-one — not a convention, a gate.

Measured on 2026-08-04 in the reference host: 120 rows in the registry, 120 plugin directories carrying a main, 120 committed OpenAPI subsets. The four remaining directories under plugin/ are declared tools, not apps, and are named in the generator's exemption list.

  1. A repository that builds a plugin MUST appear exactly once in the host's

registry. A registry row MUST correspond to exactly one plugin.

  1. The host MUST enforce both directions mechanically and MUST hard-fail. The

reference implementation is make generate, which scaffolds a missing main for a registered app and exits non-zero listing any plugin directory that has a main and no row.

  1. CI MUST additionally fail when generation produces any change, checked with

git status --porcelain rather than a diff — porcelain catches a new untracked scaffold, which a diff does not.

A gate that cannot fail does not count toward this. The reference host also carries a Go test named for this bijection which is vacuous: it skips any plugin main that does not call cloud.Serve, and that function was renamed to cloud.Listen, so it matches 0 of 120 mains and its loop body never executes. It passes unconditionally. Such a test MUST be deleted or repaired; leaving it is worse than having no test, because its name asserts coverage that does not exist. The bijection is genuinely enforced — by the generator, in CI — and that is the gate to point at.

§10 Standalone is the same binary, not a mode

A plugin MUST run alone. This is what makes "build your own cloud" a fact rather than a slogan: a third party takes one plugin, runs it, and has a working HIP-0119 service without adopting the host.

  1. Every plugin MUST have package main and MUST build to its own binary.
  2. Run standalone, that binary MUST serve the same routes it serves as a child.

There MUST NOT be a second code path — one server body, entered the same way, as §2 requires.

  1. The host MUST be able to build and load any single plugin without building the

others.

The enabled set MUST NOT be stated twice. A host derives which subsystem a child serves from the plugin it was built as. It MUST NOT additionally accept a flag or an environment variable restating that set.

This corrects a mechanism widely believed to exist: there is no --enable flag, and it MUST NOT be reintroduced. It was removed after two production outages on 2026-08-02, both caused by that list naming an app that did not exist and omitting one every other child needs. A build list — which plugins to compile — is a different thing from a mount list, and only the first is legitimate. The reference host's RUN_PLUGINS is a build list and the binary takes no argument from it.

A deployment artifact still passing --enable is a live defect, not a harmless leftover: argument parsing is fatal on an unknown flag, so the process exits at startup. The reference host's Helm chart still passes one and MUST stop.

§11 Enable and disable are runtime, never a build tag

A capability that can be switched off MUST be a separate artifact the host loads, and MUST NOT be a build tag inside public source.

A build tag leaks the shape of the private thing into the public repository — every reader sees the seams of what they are not getting — and it makes the open build a second-class citizen of its own repository, because the default build is the crippled one. It also multiplies the build matrix by the number of tags, which is how a repository acquires configurations nobody compiles.

Requirements:

  1. No //go:build tag MAY gate a paid, enterprise or optional capability.

Measured in the reference host: zero such tags and zero ee/ directories.

  1. An optional capability MUST ship as its own plugin binary, loaded by the host

exactly as any other plugin is, and verified by the same digest.

  1. Enable and disable MUST be available at run time, per plugin, without a

rebuild and without a restart of the host.

  1. A disabled plugin's routes MUST remain registered and MUST answer 503, not
  2. A 404 says the route does not exist, which is false and sends the caller

looking for a spelling mistake; 503 says it exists and is not currently serving.

  1. The control surface MUST be authenticated as an administrative operation and

MUST be audited.

The reference host implements 3 through 5 at /v1/admin/plugins with enable, disable and reload per plugin.

Reconciliation with HIP-0130. HIP-0130 (Draft) proposes a visible-source ee/ tier inside the public repository as the paid boundary. That mechanism is a build-time gate in public source and contradicts this section. Where the two differ, this section governs, and HIP-0130's tier-2 mechanism SHOULD be re-expressed as a separately-loaded artifact. The tier boundary HIP-0130 draws is not in dispute — only the mechanism that implements it. HIP-0135 remains authoritative on which repository a thing belongs in.

§12 Discovery — one registry, one live answer

A client such as an app builder MUST be able to enumerate the composition: which plugins exist, what each is called, what it serves, and whether it is running.

  1. One registry. The set of apps and their order MUST come from the same

in-code registry the OpenAPI pipeline projects from. There MUST NOT be a second list. In the reference host that registry is read by the mount loops, the build, the image, the generator and the admin surface alike.

  1. Content comes from the router, never from the registry. The registry

supplies the set and the order; what each plugin serves is projected from its own live router, per §4.2.

  1. The host MUST serve GET /v1/apps, answering from the plugins' own live

declarations, fanned out in parallel to each child. Every zip app already serves its declaration at /.well-known/zip/plugin.json (§3), and hosts already own a parallel ask-every-child mechanism, so this endpoint requires no new artifact and no new source of truth.

  1. It MUST NOT be a committed catalogue. A catalogue is a golden, and goldens

drift silently: a committed tool list in this estate held 12 entries while the binary served 365.

  1. The response MUST carry, per app: name, whether it is running, its declared

routes and ops, its artifact digest, and the identity fields of §13.

State in the reference host: the declaration exists and is auto-served by every plugin; the host does not yet consume it, emits no declaration artifacts, and has no /v1/apps. Three adjacent endpoints exist and each reads the wrong process — a child answering about itself where the fleet was wanted, or a child answering about a plugin table only the host holds. §12.3 is the one that is correct by construction.

§13 App identity — and the two places metadata dies

Each app declares its customer-facing identity:

| Field | Meaning | Values | |---|---|---| | Product | the product a customer buys | one name | | Category | how it is grouped in a catalogue | one name | | Kind | what it is | api, deployable, client | | Visibility | who may see it listed | internal (default), public | | Meters | what it bills on | zero or more meter names |

Two rules, and the second is the one that bites.

§13.1 Identity is declared in code, not in a comment. It MUST be fields on the registry's own Go types. A doc comment cannot carry it: Go drops comments at compile time and reflection never sees them, so a comment reaches a document only via a build-time AST pass — and the pass that exists (§4.1) lifts handler doc comments into an operation's description, examples and field help. It does not carry app identity, and extending it to do so would put identity in a second place. Visibility defaults to internal: a field that defaults to public publishes by omission, and the failure is unrecoverable.

Product today is derived rather than declared — it is the first path segment after /v1/ — and Meters has an existing precedent that MUST be reused rather than duplicated: the reference host already carries a required per-plugin price declaration whose zero value fails a test. Adding a second, parallel metering declaration would be exactly the defect this document exists to prevent.

§13.2 A field with no Go name is silently deleted in transit. The published document is built by two closed-struct round-trips, and neither reports what it drops:

  1. A plugin's open specification (map[string]any) is marshalled and then

unmarshalled into a closed Operation struct. Unknown keys are ignored — unknown-field rejection is not enabled — and the re-marshal walks only the struct's own fields.

  1. Each plugin's committed subset is decoded into a closed Document struct,

reaching the same Operation. There is no catch-all map and no extensions field at any level, and the weave then builds a fresh document from those values, so anything dropped at decode can never reappear downstream.

x-app survives, and it is the only extension that does, for exactly one reason: it is the only x- key with a named Go struct field (App string \json:"x-app,omitempty"\`). Measured: 2,341 occurrences of x-app in the woven document and no other x-` key at all, in the woven document or in any of the 120 subsets.

Therefore:

round-tripping structs** before it is populated anywhere.

reading it back out of the woven document rather than out of the source.

produces a document that does not contain it, with no error at any stage, and the loss is invisible until a consumer looks for a field that was never there.

§14 Fork a plugin, extend it, contribute back

The point of the contract is that a plugin is an ordinary repository. Forking one therefore requires no permission and no coordination:

  1. Fork. A plugin repository builds standalone (§10) and imports nothing of

the host's (§1.1), so a fork builds without access to the host at all.

  1. Extend. Add typed ops. The four projections (§4) follow from the

registration, so a new op reaches the specification, the tool list, the CLI and the call plane without touching any of them.

  1. Run it. Point a host at the fork's binary. The host loads a plugin by

name, path or release index and verifies its digest; a fork is loaded exactly as the original is, and no host change is needed to try one.

  1. Contribute back. A change is acceptable upstream when it satisfies the

§Conformance checklist and every gate in §8. Those gates are the review: they are mechanical, they run in the fork's own CI, and they are the same ones the original runs.

  1. What upstream MUST NOT require. A contributor MUST NOT be asked to sign

over more than the repository's licence requires, to route the change through a private repository, or to reproduce a private artifact in order to build. A plugin that cannot be built and tested from public sources alone is not conforming, whatever else is true of it (HIP-0135).

Conformance checklist

Build a conforming plugin repo in one pass:

  1. go.mod requires github.com/zap-proto/zip >= v1.18.7, plus

hanzoai/plane if it calls a peer and hanzoai/money if it handles money. Nothing from hanzoai/cloud. §8.1 proves it.

  1. One main per §2: construct, register typed ops, register peer ops on the

second app, zip.Described() check, app.Listen(zip.Addr(fallback)).

  1. //go:generate go run github.com/zap-proto/zip/cmd/zipdoc in every package

registering a typed op; zipdoc_gen.go committed.

  1. Every op typed, with an explicit WithOperationID; untyped handlers only

where the response cannot be a value.

  1. <binary> openapi <file> and <binary> declare <file> both work and both

write files. Both artifacts committed and regenerated by make test.

  1. No Org field in any In; zip.Tenant(ctx) with refusal on !ok.
  2. HIP-0119 in full: app listener :8000/PORT, /v1/ only, ops listener

:9090/OPS_PORT with /healthz + /readyz + /metrics, zero required flags, secrets from KMS, nothing from HIP-0119 §9.

  1. Root hanzo.yml with binaries: and a test: block that runs make test;

a seven-line .github/workflows/cicd.yml importing hanzoai/ci.

  1. Every gate in §8, all reachable from make test, and §8.9 proving CI calls

it.

  1. Tags minted by the pipeline after publish (§7.1). Patch bumps only.
  2. Exactly one registry row, and make generate clean — §9. The plugin builds

and serves ALONE, with no second code path and no flag restating which app it is — §10.

  1. No build tag gates an optional capability; enable and disable work at run

time and a disabled route answers 503 — §11.

  1. App identity (Product, Category, Kind, Visibility, Meters) declared

as named Go fields, and each one read back out of the WOVEN document to prove it survived both round-trips — §13.

The API surface a composition serves

These sections describe the API a composition presents to callers — the credential, the meter and the OpenAI-compatible door — not the plugin contract above. They are filed here because the HIPs that owned them were merged into this document; they constrain what a plugin serving those surfaces must do, and they are unchanged by the composition model.

API keys

The credential for programmatic access is an API key. IAM mints and verifies it, gateway resolves it to a principal at the edge, and no subsystem downstream reads it again. There are two types, and the prefix is what every consumer switches on:

| Type | Prefix | Where it belongs | |---|---|---| | publishable | pk- | browser and client code; identifies an org, authorizes nothing that spends | | secret | sk- | server side only; carries the caller's full scope |

hk- was the older name for a secret key. Nothing has minted one since iam v1.33.9, and verification no longer accepts one.

GET    /v1/keys    # the caller's keys: { type, prefix, createdAt }
POST   /v1/keys    # mint or rotate the key of { type }
DELETE /v1/keys    # revoke the key of that type

The secret is returned exactly once, at mint. Every later read returns type, prefix and creation time. What is stored is a hash of the key, never the key — a plaintext credential at rest is a defect, not a configuration choice. The subject a key is minted for is derived from the validated identity headers and never from the request body, so a caller can mint and revoke only their own.

A key carries a scope, and the scope is enforced at the gateway edge before any subsystem sees the request:

| Field | Meaning | |---|---| | models | which models the key may address; * for all | | services | which subsystems it may invoke — llm, agents, mcp, embeddings, images, audio | | rateLimit | requests per minute; default 60 | | spendLimit | ceiling in USD cents per billing period; unset means the org balance is the only ceiling | | allowedIPs | CIDR allowlist; unset means any address | | expiresAt | expiry; unset means the key lives until revoked |

Credit metering

Spend is prepaid. An org buys credits and every priced request draws the balance down, so spend cannot exceed what was bought and there is no surprise bill. One credit is one US cent — the arithmetic and the display are the same unit. One balance covers every subsystem: inference, agent runs, MCP tool calls, storage.

commerce owns the balance. The metering client is the one place this binary asks about money, and it wraps every priced request in two calls:

  1. Authorize, before the handler runs. Sufficient balance passes.

Insufficient balance is 402 insufficient_balance and the handler never runs. An unreachable commerce is 503 balance_unavailable.

  1. Record, after the handler returns, with the units the request

actually consumed.

Fail-closed is the default: when commerce cannot be reached the request is denied rather than served unpriced. Fail-open is a deliberate per-deployment choice, never the consequence of an outage. Free routes declare a price of zero and skip both calls.

A usage event says who spent, what served it, what it consumed and how it went. It carries no prompt content and no PII.

type UsageEvent struct {
    ID        string
    Timestamp time.Time

    // Who spent
    OrgID     string
    ProjectID string
    UserID    string
    KeyID     string

    // What served it
    Service  ServiceScope
    Model    string
    Provider string // the upstream that actually answered

    // What it consumed
    PromptTokens     int
    CompletionTokens int
    TotalTokens      int
    Cost             int64 // USD cents

    // How it went
    LatencyMs int64
    TTFTMs    int64 // time to first token, streaming
    Status    UsageStatus

    // Agent runs attribute each tool call separately
    AgentID    string
    AgentRunID string
    ToolCalls  []ToolCall

    Metadata map[string]string
}

type ToolCall struct {
    Name       string // tool
    Provider   string // MCP server that served it
    DurationMs int64
    Status     UsageStatus
}

Events are written to the calling tenant's own store, so a usage read is org-scoped by construction rather than by a WHERE clause, and commerce aggregates them for invoicing and reseller revenue share.

GET /v1/usage/summary       # current billing period
GET /v1/usage/timeseries    # hourly, daily or monthly buckets
GET /v1/usage/by-model
GET /v1/usage/by-user
GET /v1/usage/by-key
GET /v1/usage/events        # paginated raw events

All six accept start, end, granularity and filter. An alert is the same data read against a threshold — spend, request count or error count, over a daily, weekly or monthly period — delivered to a webhook, an email address or a Slack hook when it is crossed.

OpenAI-compatible surface

The inference surface is OpenAI's, so an existing application moves by changing two strings:

from openai import OpenAI

client = OpenAI(
    api_key="sk-...",
    base_url="https://api.hanzo.ai/v1",
)

response = client.chat.completions.create(
    model="zen-8b-instruct",
    messages=[{"role": "user", "content": "Hello"}],
)
POST /v1/chat/completions      # streaming and non-streaming
POST /v1/completions           # legacy
POST /v1/embeddings
POST /v1/images/generations
POST /v1/audio/transcriptions
POST /v1/audio/translations
GET  /v1/models

The ai subsystem holds the model catalog and the routing policy; gateway speaks the OpenAI shape at the edge and absorbs provider-specific differences. Both are subsystems of this binary, so a request arriving at /v1/chat/completions is authenticated, authorized against the balance, routed, served and recorded without leaving the process.

Deployment surfaces this enables

| Deployment | Brand | Enabled subsystems | Domain | |---|---|---|---| | Hanzo flagship | hanzo | all | api.hanzo.ai | | Osage Cloud | osage | iam, base, kms, commerce, ai, gateway, o11y, vfs | api.osage.cloud | | Lux Cloud | lux | iam, base, kms, gateway, chain | api.lux.cloud | | Zoo Cloud | zoo | iam, base, kms, ai, gateway, vfs | api.zoo.cloud | | Customer X (reseller) | custom | iam, base, kms, commerce, gateway | api.x.com |

Same image. Different startup configuration. The osage.cloud marketing site that shipped 2026-05-19 gets a real backend when this HIP lands.

Commerce — light router, NOT in PCI-DSS scope

Commerce is a thin orchestrator. It owns the customer-facing checkout flow, tenant config, billing logic, pricing logic, invoicing, and webhook intake. It explicitly does NOT:

Adyen SDK)

Commerce only handles tokens (vault tokens) and intents (payments-orchestrator references). When commerce needs to charge, it calls payments (Rust) via ZAP RPC with a token + amount + processor hint. Payments calls vault (Go) via ZAP RPC with a "Charge this token" request; vault pulls the PAN from its encrypted store, makes the outbound HTTPS to the processor, and returns the response. PAN never leaves vault.

This makes commerce CDE-connected, not CDE. Lighter controls apply (network segmentation, access control, change management) but commerce is NOT subject to PCI-DSS L1 audit.

Solo-vault CDE

Vault is the only system in PCI-CDE. Per the corrected scope:

| System | PCI scope | |---|---| | vault | CDE — the only system that touches PAN. Full L1 audit. Quarterly ASV. HSM-backed key store. Own deployment, own k8s namespace, own NetworkPolicy boundary. | | payments | CDE-connected (NOT CDE). Sees only tokens. Payments service operated in tokens-only mode. Calls vault.Charge(token, processor, amount) for the actual processor call. | | commerce | CDE-connected (NOT CDE). Light router. Only ever handles tokens + intent IDs. Mounts inside cloud like any other subsystem. | | Everything else in cloud | Not CDE-connected. Standard SOC2-grade controls. |

For this architecture to be sound, two requirements must hold:

  1. Browser-side card collection runs directly against vault (vault

ships a vault-collect.js iframe; PAN posts directly to vault from the browser, never via commerce or any Hanzo app server).

  1. Payments runs in tokens-only mode — verified by

audit of payments data flow that no code path exposes raw PAN to the surrounding Go process.

Both are tracked under the implementation TODO list at ~/work/hanzo/vault/docs/ (to be created).

PSP optionality

The same architecture supports four deployment modes:

  1. Default (Hanzo as merchant): Hanzo operates vault + payments +

commerce. Hanzo bears the PCI-DSS L1 audit. PCI scope = vault only.

  1. Hanzo as PSP for a white-label customer: customer's brand

(lux.cloud, zoo.cloud, osage.cloud) runs commerce inside their cloud deployment; commerce's payments_client and vault_client ZAP endpoints point at Hanzo's payments + vault. Customer carries no PCI obligation. Hanzo's vault has multi-tenant token namespacing per org.

  1. Customer brings their own PSP backend: customer deploys their own

vault + payments. Their cloud's commerce subsystem points its ZAP-RPC endpoints at THEIR vault + payments deployment. Hanzo carries no PCI obligation for that customer's flows. The customer holds their own PCI scope. Commerce is a swappable thin router.

  1. Single-tenant Hanzo Payments-as-a-product: customer's ENTIRE

commercial unit is payment-processing. Deploy payments + vault + a trimmed cloud as a unit. Commerce still operates as light router — no design change, only deployment shape.

Modes 1-3 share the same binary. Configuration determines which endpoints commerce talks to. The "swappable thin router" property is load-bearing: commerce never grows code that depends on a specific vault or payments operator.

Non-goals

Dokploy fork), brain (where still TS), bot, billing, pricing (separate Go rewrites slated under commerce subsumption). They can be ported to Go later if performance or operations require it; this HIP does not block on them.

their own deployments with their own PCI scope boundaries per the "Solo-vault CDE" section above. Vault is CDE. Payments is CDE-connected. Commerce talks to both via ZAP RPC. Hard rule, no exceptions — even single-tenant deployments use the three-process architecture.

pipeline / agent-building tool. Heavy native deps (torch, faiss, sentence-transformers). Runs as a separate process behind the gateway subsystem. Per the FT audit (2026-05-19), classified RED — defer to GIL-Python until torch ships cp313t (>=2.6).

column store. Uses Hanzo Datastore-native ReplicatedMergeTree + S3 disk. Out of scope. Shares S3 bucket with HIP-0107 streaming via vfs prefix (s3://bucket/datastore/... vs s3://bucket/replicate/...).

folded.** Runs as a separate process — the canonical AI console for cloud-hosted LLM operations. Integrates with cloud via HTTP + (forthcoming) ZAP-typed endpoints; consumed by the cloud subsystem (LLM control plane) and surfaced to operators as part of the AI console.

audit** (cli, erp, insights, sentry, studio) — all stay separate processes until their upstream FT-blockers clear (xmlsec, confluent-kafka, chdb, single-threaded Django/Celery assumptions, torch <2.6). Run under regular python3.13 (GIL-enabled) until then.

in-deployment service calls; cross-deployment (Hanzo ↔ Lux ↔ Zoo) stays on the existing service-discovery + bridge layer.

together. If a subsystem becomes a hot bottleneck, fall back to running it as its own binary alongside the unified one — the Mount contract supports both.

Open questions

  1. Cold start of a lazy composition. An eager host pays 116 processes at

boot; a lazy one pays the first request. The cost of the first request to a cold plugin is unmeasured per plugin, and CLOUD_PLUGIN_START (90s default, raised from zip's 10s because a plugin that opens stores and runs migrations on a cold volume misses it) is a blunt instrument. Needs a per-plugin measurement before a latency-critical prefix is left lazy.

  1. Finer plugins make the build WORSE until the floor drops. Each plugin

binary is ~40 MB of which ~35 MB is the core every other plugin also links. Splitting one capability into two plugins duplicates that core again. The sequencing rule is therefore: drop the floor first (§1.3), split second.

  1. Extension schemas. A Module route registers a route and no op, so it is

in no document, is no MCP tool and is reachable by no peer (§5). Closing it requires an extension that DECLARES its contract in its extension.json, and zip.Module surfacing that schema so the projections follow. Until an extension declares one there is nothing to project.

  1. Remote ZAP. The call plane is unix sockets today. A plugin composed

across hosts needs ZAP over TCP with session crypto and a non-Go client; both are open work in zap-proto. CLOUD_<NAME>_ADDR works over a trusted network in the meantime, which is a deployment constraint, not a contract.

  1. Third-party plugins. Every rule here is enforced by a gate the host runs

over an artifact the host verified, which is sufficient for first-party repos. An untrusted plugin additionally needs a resource bound and a capability restriction on the child process; neither is specified here.

References

to in full; this HIP adds only what HIP-0119 does not cover**

truths are absorbed here: no build tags (a capability is a plugin, compiled once, loaded when asked); a plugin is a supervised child with crash isolation and per-prefix 503 degradation; ZAP is only a transport, and reachability confers no authority. Its lpm distribution, its .zap/zapc plugin IDL and its "embedded subsystem" shape did not ship and are replaced by §3 (the declaration), §7 (hanzoai/ci + binaries.json + digest-verified install) and §1.3(a) (hanzoai/plane, a Go leaf of op names and types)

from, and the rule that nothing re-derives it.** §1.3(b) and §6 cite it and add only the plugin's own read; they do not restate it

duplicating

this contract, not restatements of it

plugin/<app>/, mk/plugin.mk, openapi/weave.go. It states how cloud implements this contract and MUST NOT restate the contract itself