HIP-0106's plugin contract, in the framework rather than in 116 repositories. Declaration projects the LIVE ROUTER — every method+pattern, complete by construction, so a route a plugin serves and does not publish is still declared. That is the analytics-ingestion outage class: the published paths were listed, the four ingestion doors were not, and every product beacon in the fleet answered 405. Declaration/Route/(*App).Declaration what a host discovers /.well-known/zip/plugin.json the same value off a running child (*App).Described `<bin> openapi <f>` / `<bin> declare <f>` Tenant the tenancy read, with the real rule Delegate a continuation for another org Ask dial a peer by name, invoke one op (*App).Peer peer ops on their own app, own socket (*App).Ops /healthz + /readyz + /metrics Config.Eager the one fact a router cannot show App.claim a second claim of a prefix is an ERROR zip/runtime -> zip/js a language runtime is an adaptor Four things the reference plugin falsified while being built: - Describe was already taken. zip.Describe(methodPath, Doc) is the doc registry generated code calls. The projection writer is (*App).Described, one call, because argv is read in one place and a main cannot forget to forward it. - zip's control plane is NOT all under /.well-known/. /docs and the MCP path are not, so a prefix filter let a child declare them — paths outside /v1/ that every plugin serves and whose first claimant takes the HOST's document and agent door. App.control now registers and records in one call, so leaking one is impossible rather than unlikely. - OPS_PORT appeared in no Go file in the fleet. HIP-0119 named the paths and nobody implemented them, so conformance meant 116 repos hand-rolling three routes and a listener. It is one sibling app now, brought up by Listen. - There is deliberately NO default ops port. A default made every second app in a process die on "address already in use" — a plugin has two apps in it and a test process has many. The deployment states OPS_PORT; a host states none on a child, so a child correctly owns none. Load's duplicate claim used to return nil while the first registration kept answering: the losing plugin's whole surface served another plugin's 404 and nothing said so. It is now an error naming both claimants, all-or-nothing across a plugin's prefixes, taken before anything is spawned. a.ops was the op registry and is now a.registry — the ONE registry the OpenAPI document, the MCP tool, the CLI command and the plane op are all projected from. a.ops is the ops sibling. Co-authored-by: Hanzo Dev <[email protected]>
3.7 KiB
Module handlers (HIP-0105)
zip mounts HIP-0105 extension modules as routes via app.Module():
app.Module("POST /v1/policy/eval", "wazero", "./ext/policy")
app.Module("POST /v1/transform", "pyvm", "./ext/transform")
app.Module("POST /v1/webhook", "goja", "./ext/webhook") // recommended for multi-tenant JS
app.Module("POST /v1/route", "starlark", "./ext/route")
Each ./ext/<name> directory contains an extension.json manifest and
the compiled artifact (e.g. validate.wasm or webhook.js). See
HIP-0105 for the manifest spec and
the calling conventions per runtime.
Wire format
zip builds one canonical JSON envelope per request and hands it to the guest. Same shape regardless of runtime:
{
"method": "POST",
"path": "/v1/policy/eval",
"params": {},
"query": {"q": "x"},
"headers": {"...": "..."},
"body": {"...arbitrary JSON..."},
"org": "hanzo",
"user": "z",
"userEmail": "[email protected]"
}
The guest returns:
{
"status": 200,
"headers": {"X-Foo": "bar"},
"body": {"...response..."}
}
If the guest returns a bare JSON value (not the envelope shape), zip sends it as the response body with status 200.
Native runtimes
Per HIP-0105 the runtime names are:
| Runtime | Languages | Sandbox | Notes |
|---|---|---|---|
native |
Go | none | compile-time linked; default for Hanzo-authored code |
goja |
JS | soft | recommended for multi-tenant JS at scale (~9 KB/module) |
wazero |
wasm | hard | language-agnostic (Rust/AS/TinyGo/Zig/C); pool default 4 |
pyvm |
Python | soft | full CPython incl. numpy/pandas; single-tenant only |
starlark |
starlark | soft | config DSL |
v8go |
JS | hard | experimental — not production (libv8 SIGSEGVs at scale) |
AllowedRuntimes
For multi-tenant deployments, restrict which runtimes are accepted:
app := zip.New(zip.Config{
Loader: loader,
AllowedRuntimes: []string{"goja", "wazero"}, // no pyvm, no v8go, no native
})
app.Module() returns an error if the manifest's runtime is not in
the allowed list.
Loader injection
zip does NOT depend on hanzoai/base/plugins/extruntime. The zip.Loader
interface is declared at the zip root and is duck-typed:
type Loader interface {
LoadDir(ctx context.Context, dir string) (map[string]Module, error)
LoadOne(ctx context.Context, dir string) (Module, error)
Runtimes() []string
}
Because the contract lives at the root and no implementation is imported
with it, a service that never evaluates JavaScript does not compile one:
goja and esbuild live in zip/js, a separate import that only the
binaries wanting JS pay for.
A real service binary constructs *extruntime.Loader from
hanzoai/base/plugins/extruntime with the runtimes it cares about
(goja + wazero + pyvm + starlark + native), and passes it in. Note that
extruntime declares its own Module type, so it reaches zip.Loader
through a thin adapter whose LoadDir/LoadOne return zip.Module: Go
demands identical result types and an equivalent method set is not enough.
loader := extruntime.NewLoader(
nativevm.New(),
gojavm.New(),
wasmvm.New(),
pyvm.New(),
)
app := zip.New(zip.Config{Loader: loader})
The duck-typed interface keeps zip lightweight (no transitive hanzoai/base dep) while letting the unified Hanzo binary wire every runtime once and share the loader across mounted subsystems.
Lifecycle
app.Module() loads the module at registration time. The module is
closed on app.Shutdown() via an automatically-registered closer.
There's no per-request load / hot-reload today; if a real consumer
asks for it, the loader interface gains a Watch() method.