The reflective encoder refuses bytes_fixed[N] — an ids.ID is [32]byte — so that an id forces its type to state its own wire. The SDK read that refusal as a fact about the op and reported a gap. It is a fact about the ENCODER: the layout is right, and Codecs already writes the two methods that carry an id inline from those same offsets. So the SDK writes one. Not a second emitter — the same one, asked for names through a seam, because two emitters would be two wires and the second would be spoken by nobody. The restatement is the declared struct field for field and in order, so its layout IS the declared layout; the codec states the declared offsets against this package's names. Which types get one is not all of them. Wire is checked at the root of Marshal and Unmarshal only, so a parent encoded reflectively reflects over its children too. That makes the set closed upward, and a codec calling MarshalZAP on what it nests makes it closed downward. One id under an op's In or Out states the wire for that tree; a tree with no id keeps the derived wire it had, and its generated source does not move. Two guards, because a wrong offset is worse than a gap: the restatement is checked against the layout it claims — same fields, same order — and a mismatch is a gap, never a guessed layout; and spell preserves reflect.Kind, which is what makes the widths identical. Over node's nine apps: 43 of 94 ops reachable in Go, now 91. The three left are an interface, which names no type. Every op is still a method XOR a gap. The document, the tool list, the command tree and both HTTP clients are byte- identical across the change.
80 KiB
zip — agent notes
github.com/zap-proto/zip. The Go web framework everything in the fleet serves
on. Built on the zap-proto/fiber fork over fasthttp. ZAP is the primary
transport; HTTP is a secondary view of the same routes.
Canonical checkout: ~/work/zap/zip. Both impostors are retired.
~/work/hanzo/zap-zip was a stale duplicate clone of this same module and has
been deleted; every ref it held is on origin, so git clone restores it if
anyone ever needs it. ~/work/hanzo/zip is github.com/hanzoai/zip, a dead
fork, now carrying a DEPRECATED.md pointing here — nothing should import it.
Upgrading to v1.18.0 — read this before bumping
Four changes are visible from outside. Everything else is additive.
-
A typed
Deleteno longer reads a request body. Its input is the URL: path params and query string, which is what the document always said and what every generated client already sent. Check everyzip.Deleteop whose In type has a field that is NOT a path param — those fields used to arrive in a body and now must arrive as?field=value. A DELETE with only path-param fields is unaffected. (GET/HEAD were already URL-only.) -
zip/zaprpcis deleted. Nothing in the estate imported it. If you havezaprpc "github.com/zap-proto/go/rpc", that is a DIFFERENT module and is untouched — see below. -
The document says more. A URL-borne field's
validate:"required"now reaches its parameter, and a bodyless op's example now rides its parameters. Regenerated SDKs will make those arguments required — which they already were at run time. -
zip.Getand friends take anOpTarget, not a*App.zip.Get(app, …)is unchanged;zip.Get(v1Group, …)now also works.RouterembedsOpTarget, so a Router implemented outside zip must add one method:func (m *myRouter) OpScope() zip.OpScope { s := m.inner.OpScope() s.Middleware = zip.Chain(s.Middleware, m.gate) // if you gate routes, GATE OPS return s }Embed the wrapped Router and override this; the embedded one answers for everything else. Known implementor:
hanzoai/commerce/middleware.Mint.
A validation failure now names the field by its WIRE name (reason), not its Go
name (Reason) — if anything matched on those strings, it matched on the wrong
one.
The verbs
Everything composes through these. There is deliberately no second way to do any of them.
| verb | what it does |
|---|---|
zip.Get/Post[In,Out](on, …) |
declare a typed op — THE way to declare a route. The schema, and what every projection is derived from. on is the App or any Router of it |
zip.WithStatus(201) |
declare the SUCCESS status; it keys the document's response, not just the wire |
app.Add(svcs...) |
compose units of functionality |
app.Listen(addrs...) |
serve here; the address scheme picks the transport |
app.Mount(prefix, addr) |
delegate there; same scheme registry, opposite direction |
app.Graft(children...) |
compose an APP in process — the parent's router learns the child's shape, the child keeps its behaviour (v1.18.16) |
zip.Call[In,Out](ctx, conn, op, in) |
invoke an op on another app; zip.Dial/DialApp gets the conn |
app.Get/Post/... |
the ESCAPE HATCH: an untyped route. No op, no schema, invisible to every projection |
zip.Get[In,Out] and zip.Call[In,Out] are functions rather than methods only
because Go methods cannot take type parameters. Declaration and invocation
therefore read alike, and both take their subject first.
The untyped verb is last in that table on purpose. app.Get(path, fn) is a
route and nothing else. Reach for it only when the response is something a schema
cannot describe — an SSE stream, a protocol upgrade, a proxied byte range, a
non-JSON body — because a typed op is one decode, one call, one serialize and
genuinely cannot express those. examples/sse-streaming and examples/websocket
are the two exemptions in the tree and each states its reason in the file header.
Everything else is a typed op. If you can name what goes in and what comes out,
declare it: that declaration IS the API.
A typed op declares on a Group as readily as on the App (v1.18.0). zip.Get
and friends take an OpTarget — *App, any Router, or app.With(mw…) — so a
group's prefix is part of the op's path, and With's middleware composes around
its handler:
v1 := app.Group("/v1")
zip.Get(v1, "/users/:id", getUser) // op path: /v1/users/:id
zip.Post(app.With(RateLimit), "/v1/keys", mint) // gated, and still one op
Until then zip.Get took the *App only, so a group-structured app had to
spell its prefix out per route to have typed ops at all — friction pushing
exactly the wrong way, since the untyped handler inherited the prefix for free.
zip composes the op's path with the router's own rule (joinPath mirrors
fiber's getGroupPath) and registers the composed path, so op.Path IS the route:
one string, no second composition to drift.
OpScope is EXPORTED, and it has to be. Router embeds OpTarget, so a
Router implemented outside zip must implement it — and a DECORATING router must
implement it faithfully. hanzoai/commerce's middleware.Mint is the case that
proves it: it wraps a Router and prepends a platform-only gate to every route it
registers. A sealed (unexported) method would have made Mint uncompilable, and a
Mint that merely delegated would register a typed money-mint op with NO GATE —
its entire purpose, silently skipped. The shape is: embed the wrapped Router,
override OpScope, and fold your middleware into s.Middleware.
One registry, eight projections
typed.go. Every zip.Get/Post[In,Out] appends one *registeredOp to
app.ops — appended in exactly ONE place (registerTyped), and that slice is
the app's schema. Nothing else is authored; every interface below is derived
from it, and op.invoke (decode → validate → authorize → run) is the ONE
handler core they all share, so they cannot diverge in behavior.
| projection | file | consumer | addressed by |
|---|---|---|---|
| REST routes | typed.go |
browsers, the external edge | method + path |
| OpenAPI 3.1 | openapi.go |
humans, /docs, the published SDKs |
operationId |
| MCP tools | mcp.go |
agents | tool name = operationId |
| CLI commands | cli.go, clispec.go |
operators, scripts | operationId → <service> <operation> |
| op-call plane | call.go |
other services | operationId |
| GraphQL schema | graphql.go |
one query over several ops | operationId |
| ZAP IDL | zapschema.go |
peers with no Go types — and it can REFUSE | method name |
| Go SDK source | sdk.go |
published clients that do not link the service | operationId |
opName(op) is the one place the id rule lives; they all agree on the token.
Since v1.17.8 Command.OperationID carries it too, and WithOperationID
renames the command as well as the document, the tool and the call target —
before that the CLI spelled its own name from the route and one op had two
names. App.OpenAPISpec(), App.MCPTools(), App.Commands() and App.SDK() read
their projection directly, with no transport in the way.
The ZAP IDL is the one that is allowed to say NO. Every other projection can
describe anything — JSON Schema has {}, GraphQL has a JSON scalar, the CLI has
a string flag — and each of those is a way of writing down "I do not know what
this is" and carrying on. A ZAP field IS an offset and a width, so a value the
IDL cannot name is a value that cannot cross. Schema.Gaps and SDK.Gaps are
therefore half the answer, not a footnote.
An untyped app.Get(path, func(c *zip.Ctx) error) registers no op, so it
appears in none of them. That is the single biggest source of surface that
"exists" but cannot be documented, called by an agent, driven from a script, or
reached by another service. Fleet-wide it is also the majority of the surface,
which is what the typing migration is for: converting a route to
zip.Get[In,Out] is what turns four dark interfaces on, and nothing else does.
app.Module() (HIP-0105 extension routes) is the one remaining registrar that
cannot register an op; module.go's doc comment states both structural reasons
and the one thing that would close it (an extension declaring its contract on
zip.Module).
PROTOTYPE (branch proto/one-verb-fold) — one verb, one walk
Not on main. Not tagged. Not released. This section describes the branch only; everything below it describes main and still holds there.
compose.go, walk.go, build.go, remote.go.
The thesis. Graft was a symptom; the EAGER registry was the disease. The
registry was a []*registeredOp FIELD that composition appended to at call
time, so composing two apps meant merging two registries, and AdaptNetHTTP
type-erasing an *App into a closure destroyed five projections at once. Make
the registry a lazy PROJECTION over a walk and composition collapses to
appending a reference.
The model. An App is a PROGRAM: an ordered list of entry{node, callsite}
where the node is one of exactly three payloads — a Handler (middleware), a
route, or an *App included by reference. Use appends the first and the
third; the route methods append the second. Group returns a child *App with
a prefix, so "a scope" and "a sub-application" are one mechanism.
Not mount, not proxy, not delegate, not embed: inclusion by reference. One
interpreter walks one program and reads the child's entries in place. There is
no run-time boundary, so nothing hops. Those words are reserved for Mount,
the one case where another runtime genuinely exists.
The walk is the contract. walk(app) ([]occurrence, error) flattens the
program in preorder — one occurrence per entry visit, all three kinds, with
prefix, middleware stack and breadcrumb. Every projection is a REDUCER over that
slice: the router, the registry, OpenAPI, MCP, CLI, the call plane, the conflict
report, the lint. Guarantees: pure (no I/O), deterministic, append-stable
(composing C never reorders A's occurrences — what keeps generated SDKs
diff-stable), and validated before any reducer runs.
The AST and the walk are unexported. They are the contract projections are written against, not a compatibility surface; the tests for them are in-package for that reason.
Snapshot semantics. A node's middleware environment is the stack inherited at its INCLUSION SITE plus the middleware preceding it at its own level. So (a) parent entries written after an inclusion site do not reach that subtree, and (b) entries written inside a subtree, whenever written, inherit the anchored environment. Contents may grow until seal; environment may not. This diverges from Fiber, where the stack is whatever had been registered by the time a route was — migration note material.
Staged composition (middleware appended after an inclusion) is legal and means
what it says. It is intentional co-located and a latent bug written far apart,
and nothing can tell those apart, so it is app.Lint() — a report naming both
call sites — and not an error at any tier.
Generations (replaces seal-forever). A program is not mutable; it is
VERSIONED. A generation is a sealed program plus everything projected from it,
and the live system is one atomic.Pointer to the current one.
- Build then swap. A composition change builds N+1 from the current entry list ± the changed refs, runs the whole walk and every validation, and swaps only on success. A colliding plugin fails the build with breadcrumbs and the old generation keeps serving — load and reload are transactional, so a bad plugin cannot take down routing.
- Requests are generation-pinned.
Listenserves a handler that loads the pointer once on arrival; an in-flight request completes on its own generation. Lock-free, no lock on the served path in any phase. - Verbs.
Usepre-build (a declaration, cannot fail, returns the receiver);Include/Dropagainst a live system (transactions, return an error, change nothing on failure). Two names because one can fail and the other cannot. - Freeze happens at first appearance in a BUILT generation, propagates across the reachable graph, and is monotonic. Direct edits panic with a message that names both call sites and says to go through a generation. Reading never freezes.
Dropis receiver-local, which answers the shared-definition question: an entry belongs to the app that wrote it, so a host drops only what the host included.Drop(billing)when a GROUP holds the reference is a no-op; drop the group. Anything else would let one host reach into a definition another host also serves.
Occurrence ids. One definition included twice declares ONE id and needs TWO
operations, or the document is invalid. Qualification is prefix-derived and
unconditional: v1.listInvoices vs admin.listInvoices. Never positional —
"first wins" and "append -2" both make generated output a function of mount
order, so reordering two lines in a wiring file becomes an SDK break. Surface
keys on the occurrence; TYPES key on the definition, so one Invoice stays one
schema.
Mount is a leaf. Mount(prefix, addr, decl...) appends an App like any
other inclusion. The declaration is a BUILD INPUT and is never fetched: a walk
that did I/O would make Registry() fallible and slow and make the document
depend on another process being up at boot. Route.Op (new, additive) is what
lets a declaration say which op answers which address.
Acceptance: the iam graft case, byte for byte
Reproduced with iamserver.NewApp(db) (hanzoai/iam v1.34.0) grafted into a
4-path host, rendered through OpenAPISpec():
| released zip v1.18.22 | this branch | |
|---|---|---|
| host alone | 4 paths | 4 paths |
| iam alone | 81 paths | 81 paths |
| after graft | 85 paths / 102 operations | 85 paths / 102 operations |
| document sha256 | e0b1924352028050… |
e0b1924352028050… |
Byte-for-byte identical. iam v1.34.0 also compiles against this branch with
NO source change — it has no bare closure at a Use call, so it needs no
zip.H.
Note the real number is 4 → 85 paths (98 iam ops + 4 host ops = 102 operations), not the 4 → 164 in circulation. 164 is not what this pairing produces; it is presumably the whole api.hanzo.ai document across every subsystem, or a different iam. The acceptance property — composition is the union, and the prototype changes no byte of it — holds either way.
Things the code proved, that the design did not predict
- Middleware placement is depth-dependent. Root (
depth 0) middleware stays router middleware — unchanged behaviour, and it still covers requests that match nothing (404 logging, CORS preflight). Middleware inside an INCLUDED definition is composed into that subtree's own route chains instead, because a pathlessapp.Use(guard)placed on the host's router is a barrier for the whole host binary — exactly the failureGraft's delegation existed to avoid. Cost: an included definition's middleware no longer covers unmatched paths, which is arguably correct (a definition does not answer for addresses it does not declare) but IS a change. - Binding a handler to an App is a BUILD step, not a registration step.
toFiberHandlerwas called at registration with the declaring app; one definition served by two apps then hands out a*Ctxbelonging to an app that is not serving. Routes carry unbound[]Handlerand bind at materialisation. - zip's own control routes are NOT part of the program. They are a projection OF it, so making them entries made rendering a projection a mutation (seal, then ask for the document, then panic) and made them inheritable by a host. They belong to the build.
- Materialising on a read is a write, and reads are concurrent. Found by
-race, not by reading.router()takes a mutex;Registry()keeps itssync.Once. The version counter is process-wide (an append to a child changes a parent's meaning), so a sealed app must stop consulting it or an unrelated App composing anything rebuilds this one's router. - Errors the router raised at registration now surface at build. Fiber's
ambiguous-route panic (
/x/:idvs/x/:name) fires atFiber()/Listen, not at the secondapp.Get. Structural and not undoable: an App can be included after its routes are written, so at any single registration the set of patterns it must coexist with is not yet known. - A no-op transaction still installs a generation. The number is a build counter, not a change counter; detecting "nothing moved" would be a second code path for one operation.
- ZAP cannot carry a free-form value. The tidy symmetry — forward a mounted op over the remote's own ZAP call plane — is impossible: ZAP encodes structs and this side has no struct, by construction. Mounted ops forward over the declared REST route with JSON, which is the boundary encoding anyway.
Composing an app in process — Graft (v1.18.16)
graft.go. app.Graft(child) puts a live *App behind a host's router and
keeps its registry. It is the fifth verb, and the one that was missing:
Listen serves here, Mount delegates there, Add composes a registrar,
Load composes a binary, Graft composes an app.
What it replaces, and why that was a hole. The only prior way was
app.All(prefix, zip.AdaptNetHTTP(childHandler)). AdaptNetHTTP takes an
http.Handler and returns a closure (adapt.go, three lines) — so the App went
in and a bare function came out, and the child's registry went with it. All
five projections die at that closure: the document, the MCP tools, the CLI
commands, the call plane and the Declaration. The host could publish only the
wildcard it hung the closure on. hanzoai/cloud published five path keys for
an identity provider holding 94 typed ops; the other 89 existed, were typed,
carried schema and prose, and reached no consumer.
AdaptNetHTTP is NOT dead — it is still the right tool for a genuinely foreign
handler (a mux, a gin engine, a reverse proxy). What died is AdaptNetHTTP
applied to a *zip.App.
Delegation, not route-copying. For each pattern the child declares, the parent registers that pattern pointing at ONE handler that runs the child's own router on the same fasthttp request. This is the load-bearing call:
- copying the child's route handlers drops its middleware —
DeclarationprojectsGetRoutes(true), which filtersUseentries out — and a service whose whole authentication seam isapp.Use(guard)would arrive with the guard gone; - copying its
Useentries too is worse: a pathlessUsegrafted onto the parent gates every route in the host binary.
Delegation has neither failure, because middleware stays inside the app that
declared it. graft_test.go's TestGraft_MiddlewareStaysInsideTheChild is
that proof, both directions.
It is also cheaper than what it replaces (no net/http round trip, so the adapter's ~5% and its dropped fasthttp user-context both go) and it narrows the surface: a wildcard swallows every unknown path under its prefix, while Graft registers only what the child declares, so an undeclared path falls through to the parent.
Six decisions, each a consequence of that one.
| rule | why | |
|---|---|---|
| paths | untouched | op.Path is already the whole absolute path; rewriting it names a route that does not exist |
| operationIds | untouched | an id is a published SDK method name; rewriting it at compose time makes an SDK method a function of where the app is deployed |
| registry | copy, with Origin |
the parent's registry is its own value, so composing never edits what the child says about itself |
| authorizer | the CHILD's | op.invoke closes over child.authorizer; the parent never silently re-authorizes a child's op under its own rules |
| tags | the op's own, else Origin |
a grafted product is a named group, not an untagged blob. Derived, never invented |
| schema names | <origin>.<Type>, always |
see below |
Schema names are qualified unconditionally, not on collision. Qualifying only
on collision makes a published type name a function of who else is in the
room: add a schema to one app and another app's type silently renames, which
renames an argument type in every generated SDK. It is one extra input to
schemaRegistry.nameFor, which already qualified by package — not a second
naming scheme. Qualifiers compose outermost-first: origin, then package, then an
ordinal. An app with nothing grafted is byte-identical to before
(TestGraft_UngraftedDocumentIsUnchanged).
The collision is real, not hypothetical: iam and the cloud fleet each own an
Application (an OAuth client, 83 props / a hiring application, 16 props) and a
Role (14 props / 2 props). Unqualified, the weave refuses with
Conflict{Kind:"schema"} and the fleet document does not build.
The refusal is all-or-nothing and reads the ROUTER, not the document. Before
registering anything, Graft checks every METHOD pattern each child declares
against the parent and against its siblings; any overlap returns one error
naming both claimants, in claim()'s vocabulary, and registers nothing. It
reads Declaration() because the addresses that actually collide are liveness
paths no document publishes — GET /healthz is the one that already caused a
production outage when a co-mingled child answered it.
No prefix argument. A child's paths are its own — a real identity provider
owns three disjoint prefixes (/v1/iam, /login/oauth, /.well-known) and one
prefix string cannot say that. A host that wants a child elsewhere builds it on
a Group of that prefix; that is the prefixing mechanism that already exists.
Lifecycle. Graft must run before Listen: the document, the tool list and
the call plane are rendered once, from the registry, at prepare(). An op
grafted after that would serve and never describe — the exact defect Graft
exists to remove — so it refuses (App.prepared). It prepares the child
(idempotent), does not adopt the child's control plane (Declaration
excludes it, so the parent keeps its own /docs, MCP door and op plane), and
adopts the child's teardown so a graft cannot leak the child's store.
When NOT to reach for it. Graft is in-process composition of a *zip.App.
A child that is a separate service over the wire (a reverse proxy, an HTTP
client to another pod) has no *App here to graft; forcing one would mean
linking that service's whole dependency graph into the host, which is the cost
Mount exists to avoid. That case needs the mirror primitive — the host reads
the child's /.well-known/openapi.json + /.well-known/zip/plugin.json across
the wire at compose time, with the same all-or-nothing refusal. Not built.
Calling another service — call.go
The cure for the mega-binary. An aggregator that reaches another app by importing its package drags in that app's whole dependency graph; one that hand-writes a client copies the schema into a second place to drift. Neither is needed — the op already has a stable name, so a caller addresses it by name over the app's own transport:
c, err := zip.DialApp("flags") // $ZIP_RUNTIME_DIR/flags.sock
out, err := zip.Call[BoolIn, BoolOut](ctx, c, "flags_bool", &in)
The plane is an ordinary route (CallPath = "/.well-known/zip/op/"), so it
rides every transport Listen was given and inherits the app's middleware,
error handler, validator and Authorizer — it is exposed exactly as much as the
REST routes and no more. The whole input arrives as the body: addressing by name
means there is no URL to carry half of it, the same way tools/call does it.
The body is ZAP, and only ZAP (v1.18.5). internal/zapenc derives a wire
layout from the In/Out type itself — fields take slots in declaration order,
each aligned to its own width, which is the rule zap's own schema builder
applies. Nothing is named on the wire: a field IS its offset. So a
service-to-service call carries the same bytes both ends hold in memory, with no
text format in the middle. JSON is the BOUNDARY encoding and stays on the REST
routes a browser reaches and in the MCP envelope an agent reads; it has no place
inside the binary protocol. Refusals cross as ZAP too (callFault), status
intact, so errors.As still recovers the *HTTPError the callee returned.
op.invoke takes the decoder as a PARAMETER, so there is still exactly ONE
handler core under REST, MCP, CLI and the call plane — the encoding belongs to
the transport, not to the contract.
The compatibility rule follows from the layout: reordering, inserting or retyping a field changes the wire. Append at the end, and only at the end.
Errors cross whole: status, code and message travel as ZAP like everything else
on this plane, so errors.As(err, &he) on the caller's side sees the
*HTTPError the callee returned, status intact. A void op yields (nil, nil).
Generic, not generated — inside the fleet. For a caller that can import the
op's In and Out, a generated per-op client would put a second copy of the schema
in its repo on its own regeneration schedule: the drift this plane exists to
remove, one directory over. Call[In,Out] is type-safe at the call site against
the types the callee declared, with nothing to regenerate. A caller wanting them
checked at compile time imports the op's In/Out — a types-only package, none of
the handler's dependencies.
Generated — for a published SDK. App.SDK(pkg) (sdk.go) exists for the
callers that CANNOT do that: an SDK's users do not link the service, and linking
it to reach three types drags in its whole dependency graph. So the types are
restated and the methods rendered — three lines around Call, so it is a face
over this plane and not a second one.
It reads the REGISTRY, not the document, and that is the whole design decision. A Go type has three shapes on the JSON edge it does not have on this wire, and each of them silently moves a field:
| JSON edge | ZAP wire | |
|---|---|---|
| field order | properties is an object, and an object has none |
LayoutOf is declaration order |
json:"-" |
absent | still a slot |
| embedded struct | flattened | one nested slot |
So the generated struct is the declared struct, field for field, in order, with only the type names changed: the layout is identical by construction rather than by a rule someone has to keep. A document-derived client would lay its fields alphabetically and read every value after the first from the wrong offset.
The SDK writes the codec too (v1.36.41)
The restatement being layout-identical is what lets the SDK do the one thing it
used to refuse. bytes_fixed[N] — an ids.ID is [32]byte — has an offset and
a width, and the REFLECTIVE encoder still refuses it (see codec.go below, and
that refusal stays). The SDK read the refusal as a fact about the op and reported
a gap, so half of node's contract had no Go client: 43 of 94 ops, while the
Rust and C++ legs carried all 94.
It is a fact about the encoder, not the op. Codecs already writes the two
methods that carry an id inline, from those same offsets. So the SDK writes one:
same emitter, same LayoutOf shape, same builder calls, with this package's
names substituted through a naming seam (name / field / spell) that
codec.go's emitter now asks instead of reading t.Name(). One emitter. Two
emitters would be two wires, and the second would be spoken by nobody.
Which types get one is not "all of them". Wire is checked at the ROOT of
Marshal and Unmarshal only, so the set has to be closed BOTH ways:
- upward, because a parent encoded reflectively reflects over its children — a nested codec is reached by the parent's method and never by the walk;
- downward, because a codec calls
MarshalZAPon every value it nests.
So one id anywhere under an op's In or Out states the wire for that whole tree, and a tree with no id in it keeps the derived wire it already had. That is the line: the ops that already crossed are not re-encoded to fix the ones that could not, and their generated source does not move.
Two guards, because a wrong offset is worse than a gap. The restatement is
checked against the layout it claims — same field count, same names, same order —
and a mismatch is a gap (no codec), never a guessed layout. And spell
preserves reflect.Kind for every kind that has a layout, which is what makes
the widths identical: int stays int, a named uint32 becomes uint32, a
[32]byte becomes [32]uint8. Kind determines width, so the layouts cannot
diverge.
Measured over node's nine apps: 43 of 94 ops → 91 of 94, and the three left
are any-rooted (admin.get_config, xsvm.get_block, xsvm.get_block_last) —
an interface names no type, so there is nothing to declare. get_block was one
of them all along; its In refused first, so nobody could see it. Every op is
still a method XOR a gap. The document, the tool list, the command tree and both
HTTP clients are byte-identical across the change — 9/9 openapi.json, 9/9
mcp.json, 9/9 commands.json, 18/18 Rust files, 18/18 C++ files — and the
three apps that had no gaps have byte-identical Go SDKs too. Only the six legs
that were wrong moved.
The ONE socket-path scheme
SocketPath(name) = RuntimeDir() + "/" + name + ".sock"
RuntimeDir(): $ZIP_RUNTIME_DIR set explicitly — always wins
$XDG_RUNTIME_DIR/zip a dev box, no configuration needed
/run/zip the system default
Both halves use it — a service serves at zip.Addr(zip.SocketPath("flags")),
a caller reaches it with zip.DialApp("flags") — which is what keeps them from
drifting. No registry, no discovery service, no second spelling: the name IS the
address, and one environment variable moves the whole fleet. socketIn(dir, name) states the shape once; the plugin loader names a child's private socket
with the same function.
Listen creates the directory 0700 if it is missing, so serving at the
canonical path works on a fresh host. An existing directory keeps its own mode:
a deployment needing a socket shared across users creates the directory itself
and points ZIP_RUNTIME_DIR at it.
Stating a type's wire — codec.go (v1.36.26)
zip.Codecs(roots...) renders the MarshalZAP/UnmarshalZAP pair for each
root and for every struct nested below it, grouped by the package that owns the
type. LayoutOf's doc named three readers of the one derivation — the plane
encodes against it, a .zap schema states it, a generator emits it as constants
— and this is the third.
Two things it buys, and the second is the load-bearing one.
- The copy stops being derived. The layout is cached; the COPY is not, so every reflective call walks the fields again and sizes a builder by guess.
- An id can cross at all.
bytes_fixed[N]is refused outright by the reflective encoder — deliberately, since an id is exactly the case that should force a type to declare its own wire — so a reply holding anids.ID([32]byte) cannot cross the plane until it has one.
Wire is checked at the ROOT of Marshal and Unmarshal only, so a nested value
still goes through the reflective path: the set of emitted types is closed
downward, and a parent's codec reaches each nested value BY ITS METHOD. The
exception is a value with NO SLOTS — a time.Time, a netip.AddrPort, anything
whose fields are all unexported. It crosses as a complete and empty object and
carries nothing, so the parent writes those bytes inline and there is no method
to reach for. The value is lost either way; that is the reflective encoder's
answer too, and answering differently would be a different wire.
The emitted file depends on the ZAP builder and nothing else, and declares no
package-level name of its own. Wire is restated in it, the way
internal/zapenc restates it, so a leaf module can state its wire without taking
on this one's dependencies — but written INLINE per assertion rather than as a
named interface, because a generator cannot know which identifiers a package has
free. github.com/luxfi/utxo imports a package called wire. Every helper is
block-local for the same reason. The assertion is what makes a deleted codec a
build failure rather than a silent return to reflection.
The wire does not move, and that is held, not asserted:
internal/codec.TestTheCodecKeepsTheWire requires the generated bytes to equal
zapenc.Marshal's byte for byte over a fixture carrying one field of every form.
That is what lets a codec roll out one pod at a time. TestTheCodecIsCheckedIn
regenerates and diffs, so a type that moved without a regeneration is a failing
test rather than a wire that moved under its readers.
The peer that is not a peer — here.go (v1.26.1)
Sometimes the app a caller names is this process. A fused binary mounts many apps; a service asks one of its own ops. Dialing the canonical socket for that is a loop — encode a value we already hold, hand it to the kernel, read it back, parse it into a copy — and only the ADDRESSING ever needed a wire.
So a bound listener records which *App answers for it, and two calls read that
back:
if a := zip.Serving("commerce"); a != nil { // is it here?
return zip.Here[BalanceIn, Balance](ctx, a, "finance_balance", &in)
}
out, err := zip.Ask[BalanceIn, Balance](ctx, "commerce", "finance_balance", &in)
Here is Call with the Conn removed. It finds the op with the same
opByName the socket plane uses and runs it through the op's own seam, so it
validates and authorizes identically — registeredOp.direct and
registeredOp.invoke share one contract closure, and there is no path into a
handler that skips a check another path makes. What direct does not do is
decode, or bind a URL and headers: there is no request, and the caller supplies
the whole input at once. The reply is the handler's own value, not a copy —
read it, do not write it.
Serving(name) is a fact about the PROCESS, keyed on SocketPath(name) so
there is no second opinion about where an app lives. It goes true when the
listener binds and false when it closes, before the listener does, so the
in-process door and the socket never disagree about whether the app is up. Nil
means "not here" and never "not deployed" — absence in the DEPLOYMENT is the
router's word.
Why it exists: cloud reached its own co-resident subsystems by building an
http.Request and dispatching it into its own router, which re-ran every edge
middleware — including middlewares that themselves read the subsystem being
served, so a read re-entered the app without bound. The cure there was a depth
cap keyed on a goroutine id parsed out of runtime.Stack. A direct op call
cannot express that shape: it enters one op, not one router.
Who is calling — caller.go
Two questions, two authorities, neither answered by the caller:
- WHO the request is for — the gateway's assertion, in the
X-Org-Id/X-User-*/X-Request-Idheaders (named once, aszip.Header*).c.Forward()propagates them onto the next hop;zip.CallerOf(ctx)reads them inside a typed handler (an untyped one hasc.Org()and friends).Callforwards only what the ctx already carries — a barecontext.Background()forwards nothing, so an unattributed call looks unattributed. A background caller that legitimately acts FOR a tenant says so withzip.WithCaller(ctx, zip.Caller{Org: …}); an inbound request always wins over what it stated, so it can supply an identity but never launder one.
The forwarded set is the WHOLE assertion (v1.18.4). identityHeaders was
five and is now nine: org, project, user, name, email, owner,
isAdmin, isOrgAdmin, request-id. The four that were missing are the four a
callee decides on — a billing subject prefers the minted X-User-Name over the
opaque id, and platform sudo is read off X-User-Owner (membership of a
reserved org), never off isOrgAdmin, which says only "administers their own
org". Forwarding a subset meant the same handler decided differently depending
on whether it was reached over REST or over a call: owner arrived empty and
isOrgAdmin false, silently, in the direction that bills or admits the wrong
principal. TestForwardedIdentityCrossesWhole fails on exactly those four if
the array is ever narrowed again.
- WHAT is calling —
zip.PeerOf(ctx)/c.Peer()return the kernel'sSO_PEERCREDreading of the peer process (pid/uid/gid) on a unix socket. The peer never sends it, so there is nothing to forge.nilmeans "this host cannot attest the caller" (tcp, or a non-Linux host) — fail closed on it; never read a zeroPeeras "attested as root".
An argv flag, an env var or a shared secret would all be things a caller states
about itself. SO_PEERCRED is the one thing about a caller it does not get to
say — which is why a service-to-service call over a unix socket needs no
credential of its own.
Cost, measured. callerContext attaches the live request to the typed-op
context, so Benchmark_TypedRoute/typed went 28 → 29 allocs/op (1298 →
1346 B/op; ns/op inside noise). That one context.WithValue is what makes both
reads possible; the alternative — copying five headers onto every typed request
to serve the few that forward them — costs more and helps fewer. The untyped
path is untouched at 27 allocs/op, and
TestServePathAllocsAreChainDepthInvariant still pins it at one heap value per
request. Do not "fix" the 29 by removing the attachment; you would be removing
CallerOf and PeerOf.
Transport is a value
transport.go. One registry, map[string]Transport, where
Transport{Serve, Dial} is a scheme in both directions. Listen uses Serve,
Mount uses Dial; a scheme may implement one and the other reports which was
missing. DefaultScheme = "zap", so a bare address is ZAP.
The scheme names the protocol; the address names where it is spoken.
networkOf reads unix-vs-tcp off the address shape (leading /, ./, or @),
which is why there is no separate zapuds scheme — one wire, one scheme.
Adding a protocol is one RegisterTransport call and changes neither Listen
nor Mount.
MCP rides ZAP, and HTTP rides MCP (v1.27.0 — SHAPE CHANGE)
mcp.go, plus the new module github.com/zap-proto/mcp (zapmcp).
The door is App.MCP(ctx, *zapmcp.Frame) *zapmcp.Frame. It takes a value and
returns one. It names no transport, holds no request, and is literally a
zapmcp.Handler, so serving the fleet's tools over ZAP with no HTTP listener
at all is one line:
srv := &zapmcp.Server{Network: "unix", Addr: sock, Handler: app.MCP}
srv.ListenAndServe()
Config.MCP.Addr does exactly that from inside Listen, on the same footing as
Config.OpsAddr: empty binds nothing, a deployment states the address.
HTTP is now an adapter over the door, not the thing it is made of. The /mcp
route reads a JSON-RPC body into a frame, calls App.MCP, and writes the answer.
That is the entire HTTP surface of MCP — nine lines in installMCP, plus
mcpSend.
What was actually wrong before
mcpCall(fc fiber.Ctx, req mcpRequest). The door's own signature took a fiber
context and read the caller's headers off it, which means a tools/call could
not exist until an HTTP request did. "ZAP-native MCP" was true only in the
sense that ZAP carried the HTTP request that carried the tool call — the
transport sat underneath the protocol, exactly backwards from how zaphttp and
zapmcp are meant to sit beside each other on the wire.
zap-proto/mcp
The sibling of zap-proto/http: same seams (Server/ListenAndServe/Serve,
Dial/Transport/Do), same offset discipline in the codec, neither depending
on the other. It implements schema/zap_mcp.zap, which had described this wire
since that repo opened while only TypeScript spoke it.
One value, two projections. zapmcp.Frame renders as the ZAP wire
(zapmcp.Marshal) and as the JSON-RPC 2.0 message (json.Marshal — the Frame
implements json.Marshaler). zip no longer hand-rolls the envelope: mcpResult,
mcpErr, idOrNull and mcpRequest are gone, and the bytes an agent reads over
HTTP and over ZAP are two renderings of one value rather than two encoders to
keep in step.
Consequences worth knowing
- The caller comes off the ctx, not off headers.
listreadsCallerOf(ctx).Orgwhere it used to readfc.Get(HeaderOrg), so the per-caller half oftools/listworks on the ZAP door too. A ZAP server states the caller withWithCallerafter deciding who the peer is. headerOf(ctx)(caller.go) is the declared-header reader, resolved from the context. Where a request is behind the ctx it reads that request's headers; where none is, it is nil — which is the honest answer a frame has, and the same ruleCallerOfalready followed.- A relayed tools/call now forwards IDENTITY only.
relayrenders the message for the hop instead of passing the inbound request through, so a child receives the caller's identity viaforwardIdentityand not every header the edge happened to carry. That is also what lets a host relay from a door with no HTTP request in hand. ask(wasaskOpen) goes throughrelay, so there is one hop function instead of two.- A malformed message with neither method nor id now answers
-32601where it used to answer HTTP 202. - The tool list renders for whichever door asks first (
mcpOnce). It used to be rendered by the HTTP installer alone, so an app served only over ZAP answeredtools/listwith[]— the door working and having nothing to say.
The gates
TestMCP_ServesOverZapWithNoHTTPListener— assertsprepared == false, zeroservers, zerolistening, and then drives a full MCP session.TestMCP_BothDoorsAnswerTheSame— the ZAP socket and the HTTP route must return byte-identical results for the same message.TestFiberIsNotInTheDispatchPath— AST check: none ofApp.MCP,tool,answer,list,source,ask,relaymay name afiber.anything, in a signature or a body. The regression that would undo this is one line of "just readfc.Gethere", and it would pass every behavioural test because the HTTP door would keep working.
zaphttp and fiber are not two HTTP paths
Worth stating because it looks like duplication and is not. zap-proto/http is
the wire — it reads ZAP frames and reconstructs a *fasthttp.RequestCtx.
zap-proto/fiber is the router — it takes a fasthttp.RequestHandler and
dispatches by method and path. They run in series (wire → router → handler), not
in competition, and the transport registry proves it: the zap scheme serves via
zaphttp.Server and the http scheme via fasthttp.Server, and both hand
the very same fiber handler in. There is one router and two wire terminations,
which is the minimum: plain HTTP has to be terminated by something that speaks
plain HTTP.
Services and plugins
service.go, load.go. Service is func(*App) error. A constructor taking
dependencies and returning one is that curried, so a composition root only sees
Service. zip.Load(Plugin, prefixes...) returns one too — which is the whole
point: a linked-in service and a separately-built binary are the same type, so
where a service runs is a deployment decision, not a code change.
Plugin takes exactly one of Addr (already running), Bin (the binary,
normally go:embed'd), Path (on disk), or URL+Sum (a release artifact).
Non-Addr plugins are started as a child process on a private unix socket in a
0700 dir.
The release lane. URL without Sum is refused — fetching code over a
network and running it is where a host becomes an ACE vector — and the digest is
verified BEFORE the file is made executable or given its final name, so a
substituted artifact is a missing plugin, never a wrong one. The digest is also
the cache key: a restart is offline and a rollback to bits this host already ran
is free. Plugins() reports Source:"url", Version:<sha256>.
The artifacts come from the SAME pipeline as everything else — root hanzo.yml,
binaries: block, hanzoai/ci (see examples/plugin/README.md). zip's own
hanzo.yml publishes examples/plugin/billing per OS/arch on every tag as the
reference artifact, so any host can exercise its install path without building
anything:
https://github.com/zap-proto/zip/releases/download/<tag>/example-billing-<os>-<arch>
https://github.com/zap-proto/zip/releases/download/<tag>/binaries.json # url + sha256
Reload invariants — break any one and repeated reloads leak:
- Routes register once, at
Load, and resolve their target per request viamountVia. Re-registering on reload grows the route table without bound. - The replacement must be listening before any request moves to it, so a failed start changes nothing and a bad build cannot take a route down.
- The old process is killed, reaped exactly once (one
cmd.Waitper process, its result on a channel), its pooled conns closed, its dir removed.
Unload leaves routes answering 503 rather than removing them — same reason.
503 also beats 404 on the wire: 404 says "no such API" and is cacheable, 503
says "exists, currently down" and is retryable. PluginStatus.Disabled
separates a deliberate stop from a crash, since both answer 503. Unload also
pins a LAZY plugin down — otherwise the next request through its prefix would
start it straight back up, and disable would not stick for the plugins a
many-service host actually runs.
ReloadTo(name, Plugin{URL, Sum}) is Reload to a DIFFERENT artifact — the
version-pin and the rollback. Same three invariants (it is the same code path);
the digest is the cache key, so rolling back to bits this host already ran
touches no network. Reload(name, bin) is that call with a Bin set.
Go plugins (-buildmode=plugin) are not used and cannot be: they require cgo
(so they break the CGO_ENABLED=0 scratch images), demand byte-identical
builds of every shared dependency, and can never be unmapped, so an in-process
plugin's code and goroutines are unreclaimable for the host's lifetime.
Routing precedence
From the fiber fork: most specific pattern wins regardless of registration
order (static ≻ :param ≻ *), and equal-specificity overlaps panic at startup
rather than silently shadowing. A Mount is an ordinary wildcard route, so
a more specific route registered afterwards still wins — pinned by
TestMount_StaticBeatsRemoteMount.
The status is part of the contract (v1.18.2)
WithStatus(code) declares the status a successful op answers with — 201 for an
op that creates, 202 for one that accepts work it has not finished. Without it an
op answers 200, or 204 when the handler returns a nil Out.
It is an OpOption rather than something a handler sets per request because the
status is a CONTRACT detail, and the contract is what the registry projects.
op.Status keys the document's responses object, so a generated SDK expects
the status the service actually sends. A handler that reached around the
framework to set 201 wrote that detail into a side channel no projection could
read: the document said 200, every SDK said 200, and the wire said 201. That is
the same failure as a query parameter's required-ness being invisible — a
contract detail that only exists at run time is not a contract.
It is HTTP-only by construction: the REST route and the document read it, and the MCP tool, the CLI command and the op-call plane are untouched, because each of those carries its own outcome. A non-2xx panics at declaration — an error status is the error a handler returns, and two places to say it is one too many.
A refusal is written in the vocabulary its address speaks (WIRE CHANGE)
Every non-2xx zip answers is an RFC 9457 problem document under
application/problem+json:
HTTP 402
Content-Type: application/problem+json
{"type":"about:blank","title":"Payment Required","status":402,
"detail":"spend cap exceeded","instance":"/v1/chat","cap":5000,"spent":5127}
HTTPError is unchanged as a value and maps onto the document exactly: Status
is status, Msg is detail, Code is the extension member a client
dispatches on, and With(...) members merge at the top level (§3.2) with the
document's own written last so a domain key called status cannot displace the
refusal's. title is the status phrase, omitted for a status that has none.
instance is present when the document was served and absent when the value was
marshalled on its own — a refusal does not know its own occurrence, and a
package-level var ErrGone = zip.ErrNotFound(…) is one value shared by every
request that returns it.
This REPLACES {status, code, error}. A client reading body.error reads
body.detail now; there is no second spelling.
405 carries Allow (RFC 9110 §15.5.6), listing the methods that address
does serve. It comes from the fiber fork's own method-mismatch scan and survives
the error handler; TestAWrongVerbSaysWhichVerbsWork drives a real request and
pins both directions — every method the address serves is listed, and no method
it does not.
The one carve-out, and how an address declares it
RFC 6749 §5.2 STATES the token endpoint's error body as
{error, error_description} under application/json; RFC 7662 §2.3 and RFC
7009 §2.2.1 send introspection and revocation to that same section. A problem
document there is not a nicer error, it is a broken authorization server. So
that family declares its vocabulary where it is registered:
oauth := zip.OAuth(r)
oauth.Post("/v1/iam/oauth/token", token)
oauth.Post("/v1/iam/oauth/introspect", introspect)
oauth.Post("/v1/iam/oauth/revoke", revoke)
OAuth returns a Router, so it takes typed ops the same way
(zip.Post(oauth, …)) and nests through Group. There is no flag, no config
field and no second error type: the same HTTPError renders as
{error: Code, error_description: Msg}, and a refusal the framework produced
gets the code its status implies — invalid_client for 401, server_error for
5xx, invalid_request otherwise.
It is a property of the ADDRESS and not of the returned value, because the refusals that most need translating are the ones no handler wrote: a body that will not bind, a panic recovered above the handler, a gate refusing in front of it. There is no return statement to attach a type to for any of those.
The fact rides the route ENTRY (route.oauth, stamped once in App.addRoute
from the definition it was declared on) and is reduced to an address set by
composeOAuth at materialise, alongside composeOps. That is what makes it
survive composition: a service grafted under a host answers the same way at its
new path, where an address recorded at registration time would name a path that
no longer exists — pinned by TestTheVocabularySurvivesComposition.
The pair is METHOD+path, so a verb the endpoint does not serve is the ROUTER
refusing, and answers a problem document with Allow — which is the answer that
client actually needs.
middleware.Recover RETURNS its refusal rather than writing one. Writing a body
made it a second error surface that spoke only the default, so a panic at an
OAuth address answered a shape no OAuth client parses.
The schema derivation — one type, one definition (v1.17.8)
openapi.go. schemaOf(t, reg, fields) is the ONE derivation from a Go type to
JSON Schema. Everything that needs to know what a value is reads it: the
document's request/response schemas, its query and path parameter types, the
MCP tool's inputSchema, and the CLI's flag kind.
schemaRegistry is where a named struct is described once. Every use of it
is a $ref; the projection supplies the map and the JSON Pointer prefix that
reaches it (#/components/schemas/ in a document, #/$defs/ in a standalone
schema). The entry is claimed before the type's fields are walked, and that
claim is the cycle guard.
Before it, the registry parameter existed and was never written to. Two
consequences, one cosmetic and one fatal:
- no
$refsharing — a type reached by ten ops was inlined ten times; - no cycle guard —
type Node struct { Children []Node }recursed forever. A self-referential In type overflowed the stack while building the spec AND while listing the MCP tools. A Go stack overflow is not recoverable, so the service could not start.schema_test.gopins both the self-referential and the mutually-recursive case; before the fix those tests did not fail, they killed the test binary.
rootSchemaOf is the standalone form MCP needs: an inputSchema has to BE the
object rather than a pointer at one, so the root is inlined and anything else it
reaches travels alongside in $defs.
What this changed on the wire. A tool whose In reaches no other named struct
is byte-identical to before — $defs is dropped when nothing refers to it, which
is every tool that existed before this change (a recursive one could not be
served at all). A tool whose In has a nested named struct now carries
{"$ref": "#/$defs/User"} plus a $defs block instead of the type inlined at
each use. That is standard JSON Schema 2020-12 and it is deliberate: it keeps the
tool list describing the same definition the document does, and it stops a type
used five times from being sent five times in a payload agents receive on every
conversation. Deliberate, and worth knowing if you consume MCPTools() by
walking properties without resolving $ref.
Two packages may both call a type Config. The registry qualifies the second
with its package (pkg.Config) instead of letting it overwrite the first; before
that both ops' $refs pointed at whichever arrived last. Since v1.18.16 the
same rule takes one more input — the app that DECLARED the op, when the op came
in on a Graft — and applies it unconditionally rather than on collision. See
the Graft section for why on-collision is the wrong trigger for a published
name.
flagType (cli.go) reads schemaOf and maps the answer with specType — the
SAME schema-type → flag-kind rule the spec-derived CLI uses — rather than
re-deriving the vocabulary from reflect. A flag spelled from a Go type and the
same flag spelled from that type's published schema are equal by construction.
urlFields is the one list of URL-bindable fields, read by the path parameter
declarations, the query parameter declarations AND the CLI's flags for a
bodyless op — because bindURL is one binder over both halves of the URL, and
those are exactly the fields it can fill. Path params used to be hardcoded
"type":"string" while query params consulted the input type; the CLI offered
flags the URL could not carry.
jsonFieldName is the wire-name rule, and validate.go reads it too (v1.18.0):
a failed validation names the field the way the CALLER named it. It used to
report the Go name, so someone who sent reason was told field "Reason" is
required — a name appearing nowhere in the document they were working from.
The doc comment reaches every prose surface (v1.17.6)
cmd/zipdoc lifts the handler's doc comment and its In/Out field comments into
zip.Describe, and docFor is the one accessor. Three surfaces read it:
openapi.go (spec description + parameter/schema prose), cli.go (command and
flag help), and — since v1.17.6 — mcp.go (tool description, and
schemaOfDoc so the inputSchema properties carry their field comments).
mcpTools used to read op.Summary alone. That is only set by an explicit
WithSummary, so any op documented the canonical way — a doc comment and no
WithSummary — projected into a tool with an empty description over a schema
whose fields said nothing. It was invisible because the spec looked right: the
same op carried hundreds of characters of prose in openapi.json. Measured on
hanzoai/cloud, all 164 tools had an empty description before the fix and all 164
have one after (324 documented fields). WithSummary remains the fallback, so an
op in a package zipdoc never ran over still names itself.
If you add another projection that shows prose, read docFor — do not add a
second prose field. (The op-call plane shows none: a service reads the schema,
not the sentence, so it consumes opName and op.invoke only.)
What lives elsewhere
zip does not define a ZAP envelope, registry, dispatcher or listener.
zap-proto/go owns the wire format and zap-proto/http owns HTTP semantics
over ZAP frames. As of v1.18.0 zip holds no second implementation of any of
them.
zaprpc/ — DELETED in v1.18.0, and never what cloud imported
Settled, so nobody has to work it out again: github.com/zap-proto/zip/zaprpc
and github.com/zap-proto/go/rpc were different packages in different modules.
cloud's internal service-to-service plane imports the LATTER, under the local
alias zaprpc — zaprpc "github.com/zap-proto/go/rpc", in rpc.go, dial.go
and zapface/*. That alias is the whole reason the two got confused, and it is
why the deletion touches cloud not at all.
zip/zaprpc (deleted) |
zap-proto/go/rpc (what cloud uses) |
|
|---|---|---|
| request | Envelope{Service, Method string, Payload []byte} |
Call{Method, PromiseID, Target uint32, Cap, Payload} |
| header | 24 bytes | 28 request / 20 response |
| addressing | service+method strings | u32 ordinals, promise pipelining |
A peer speaking one could not decode the other, so zip/zaprpc was unreachable
by the rest of the ZAP fleet; nothing in the estate imported it, including zip.
Its docs cited app.ZAPRegistry(), a method that never existed in this module
(the only implementation was in the dead hanzoai/zip fork). To reach another
service use the op-call plane (zip.DialApp + zip.Call); for the ZAP wire
format use zap-proto/go/rpc, which owns it.
Testing
go test -race ./.... The plugin tests build two genuinely different binaries
(-ldflags -X main.version=) from internal/testplugin and assert which one
answered, which is the only honest way to prove a reload swapped processes.
A bodyless method, spelled once (v1.18.0 — WIRE CHANGE)
hasBody is the ONE rule about what a method carries on the wire, and it is now
the only spelling of it. It used to be documentation for a rule three other
sites re-stated differently, and DELETE had drifted:
| site | was | now |
|---|---|---|
openapi.go hasBody |
GET, HEAD, DELETE → no body |
unchanged, and now the only definition |
typed.go route handler |
method != "GET" && method != "HEAD" → read a DELETE body |
hasBody(method) |
cli.go Remote.Invoke |
same two methods → sent a DELETE body | hasBody(c.Method) |
cli.go bindIn |
every field became a flag | a bodyless op's flags are urlFields |
What changed on the wire: a typed Delete no longer reads a JSON request
body. Its input comes from the URL — the path params and the query string,
which is what every client generated from the document already sent. A service
that shipped a DELETE whose caller put fields in the body must move them to the
query string. Nothing else moves: GET/HEAD were already URL-only, and every
method with a body still has one.
It is about the WIRE, not about the op. op.invoke still decodes whatever JSON
it is handed for any method, because a call addressed BY NAME — MCP tools/call,
zip.Call, a CLI command — has no URL to carry half the input in. There the
arguments object is the whole input.
Two things a bodyless op used to lose in the document are closed with it:
- required-ness reaches the parameter.
urlField.requiredreads the samevalidate:"required"tag the body schema does, so a field the handler refuses to run without is required in the document. It used to be hardcodedrequired: false, so every generated client made the argument optional. - the example survives. A bodyless op has no
requestBodyfor the doc comment's Example to live in, so it rides the parameters that carry its values (exampleFields), andCommandsFromSpecrebuilds the object from them (specOp.example). Every GET and DELETE reached the reference and the generated CLI with no example at all before this.
TestCLI_SpecAndRegistryAgree is now plain equality across every field of every
command for every method — no skip, no divergence helper. That test is the pin:
the registry-derived command tree and the document-derived one are one tree.
Known bug — zipdoc: module-load extraction diverges from package-load
zipdoc -check ./... from a consumer's module root can flag files as stale
that zipdoc -check run in the package's own directory (and go generate,
which writes the same bytes) call clean — and WHICH files it flags varies
between runs (observed 2→4 in hanzoai/cloud across consecutive runs; per-pkg
mode stable across 15 packages). Render() is fully sorted, so the divergence
is in extraction under whole-module packages.Load, not in emission.
Until fixed, gates must invoke -check per package directory — exactly the
load mode the //go:generate directive uses. hanzoai/cloud's make test does
this; copy that shape, not -check ./....
The Zip specification
Canonical. Two documents, one dependency rule: the runtime may cite the language; the language does not cite the runtime. The code implements this.
Zip: the language
Zip is a language for describing an HTTP application. An App is the program. The walk is the interpreter. Everything else — serving, OpenAPI, SDKs, CLI, MCP, llms.txt, loaders — is a backend.
One principle above the rest: the process that composed the program is the only authority on it. Every backend is a reducer over the same tree. There is no second tool because there is no second authority.
This document is complete without the runtime (zip-runtime.md) or any loader (zip-deployment.md). If those change, this file does not.
The program
app := zip.New()
app.Use(logging) // middleware: wraps what follows
app.Get("/health", health) // operation: answers an address
v1 := app.Group("/v1") // a child App with a prefix
v1.Use(auth)
v1.Use(users) // another App, composed in
// hand the finished tree to a runtime: see zip-runtime.md
Three kinds of thing, and only three:
- App — an interior node. It has a prefix (possibly empty) and an ordered list of entries. An App may appear in two places: one definition, two occurrences.
- Handler — middleware. Wraps requests. Never answers one.
- operation — a leaf. Answers one address with one typed op.
Registered by
Get,Post, etc. Schemas come fromOp[In, Out].
Order is meaning. Use(a); Use(b) is a program, not a set.
Constructors are not structure. Root, group, plugin, remote, feature —
none of these is a node kind. Anything that returns an *App composes:
Group(p) is New with a prefix plus Use; a loader is a constructor
defined outside the language. The walk cannot tell a loaded App from a
written one. That is the extension point, and it is the whole extension
point.
Composition API
| Name | Does |
|---|---|
New() *App |
an empty program. |
Use(...Component) |
appends middleware and Apps, in order. |
Group(prefix) *App |
creates and appends a child App. |
Get/Post/...(path, ...Handler) |
appends an operation. Closures work directly. |
zip.H(func(Ctx) error) Handler |
adapter, needed only for bare closures passed to Use. |
Component |
sealed: Handler and *App. Exported so []zip.Component is writable; not implementable outside zip. |
Representation
type entry struct {
n node // Handler | operation | *App
site callSite // runtime.Caller at registration
}
node is a sealed marker. One type switch exists, in the walk. Call sites
are mandatory: every error and lint finding points at file:line, and you
cannot add call sites to entries that never recorded them. App fields are
unexported; the entries slice is not a public surface.
The walk
walk(app *App) ([]occurrence, error)
One function, and the only code that inspects entry payloads as
Handler | operation | *App. Depth-first, in entry order, carrying
context: prefix path, middleware stack (copies share structure), origin
trail (root → billing → /v1). It returns the flattened result — one
occurrence per visit: (def, kind, ctx, site).
occurrence is unexported and non-semantic: the walk's internal
flattened result, not a public model, not a stored source of truth. The
tree remains canonical. The materialized form is deliberate: the
alternatives are rewalking once per reducer, buffering secretly (the
slice exists but the document lies), or rendering during traversal
(weakening validation-before-rendering). At real scale — a thousand-odd
routes — the slice is free.
Binding requirement. Every validator and reducer consumes the walk's
result; none reopens App.entries or recurses the tree. Downstream code
may switch on a normalized occurrence kind, never on payload types. A
package test enforces it mechanically: the only type switch over node
lives in walk.go.
Pipeline, in order, or nothing publishes:
App tree
→ walk once
→ structural validation, complete (cycles, conflicts, inert middleware)
→ derived validation (ID derivation, post-derivation collisions)
→ reducers (router, registry, SDK, MCP, CLI, llms.txt)
→ publish — or write nothing
- Occurrences, not definitions. An App referenced twice appears twice, each with its own context. Definition identity is the pointer, valid because frozen definitions never change.
- Pure. No global state. Both validation stages complete, with trails, before any reducer runs.
- Deterministic. Same program, same slice, every time. This is why every generated artifact is reproducible, and why any two backends agree.
- Append-stable. Adding an entry never reorders occurrences inside previously composed subtrees. Order only: a new entry can still create a conflict that fails validation.
What an operation can express
An operation's input is its whole contract. Three sources bind it, in increasing authority — body, then declared headers, then the URL — because the URL is the addressing authority and a body must not be able to contradict the address it was sent to.
type ListIn struct {
Tenant string `json:"tenant" header:"X-Tenant"`
ID string `json:"id"`
}
A header: tag makes a request fact part of the contract, so it appears
as a header parameter in the document, a flag on the command, and a
property in the tool schema. The alternative — a middleware parking the
value in a context slot — moves the same fact through a channel no
projection can see, and a fact no projection can see is not a fact the
API has. On a transport with no HTTP request the answer is nothing, not
a panic: the field simply takes whatever the arguments supplied.
Response headers. An answer states the headers it carries the way it states its code — declared on the op, valued by the returned value:
func (r *ReportOut) ResponseHeaders() map[string]string {
return map[string]string{"Cache-Control": "no-store"}
}
zip.Get(app, "/v1/report", report,
zip.WithResponseHeader("Cache-Control"))
A header a caller relies on — a cache directive, a payment challenge, a
Set-Cookie — is part of the contract, so the document names it under
the response. An undeclared one is refused rather than written. They are
written wherever the op OWNS the response: over REST, and over the
by-name call plane. They are not written over MCP, where one HTTP
response carries a whole JSON-RPC envelope and there is no per-op
response to put them on — the declared set still appears in the schema,
and the answer's value is identical on every transport.
A failure with a body. A declared status may be non-2xx, for an op whose failure carries a TYPED BODY a problem document cannot express — a 409 with the conflicting record, a 404 with what was searched for. An error RETURN still renders the RFC 9457 problem document and is still the way to answer a failure that has nothing extra to say.
A verbatim body. A passthrough that forwards bytes to an upstream
owning the contract takes json.RawMessage as its input: every unnamed
field survives, and it needs no raw-request accessor. The opaque type IS
the declaration.
Acting for another org. zip.ActingAs(ctx, org) derives a caller
acting on another org's behalf. It cannot invent one — with nothing
authenticated it refuses — so stating an identity still cannot override
an authenticated one. The derived caller carries BOTH facts: Org is
whose data the call is for, ActedBy is who chose to touch it, and both
travel on the wire so an impersonation is distinguishable at every hop
and in every audit row. It does not authorise; whether a principal may
act for an org is policy, and policy lives in an Authorizer.
The caller's address. zip.CallerOf(ctx).IP is ambient rather than
declared — it is what the connection says, not something the caller
states about itself, so it does not belong in the op's input shape. It is
the socket peer by default: X-Forwarded-For is attacker-controlled
input, and an IP that silently reports the load balancer is worse than
none, because someone will rate-limit or audit on it. A deployment behind
a proxy opts in explicitly and the opt-in is allowlist-gated. Empty when
no connection is behind the context.
Two success codes. A route whose 201-vs-200 is a per-request decision declares both, and the ANSWER states which one it is:
func (r *CreateOut) StatusCode() int {
if r.Existed { return 200 }
return 201
}
zip.Post(app, "/v1/things", create, zip.WithStatus(200, 201))
The status rides the value the handler already returns, so there is no mutable slot on the request — a slot is a side channel, and the document publishes exactly the declared set. A code the op did not declare is refused at the seam rather than written to the wire.
Semantics
Snapshot scope. A thing sees the middleware written before it:
app.Use(a)
app.Use(pub) // pub sees a
app.Use(b)
app.Use(priv) // priv sees a, b
Anchoring. A subtree's environment is fixed where it was composed, not where its contents were later written:
v1 := app.Group("/v1")
app.Use(auth) // after the Group: does not reach v1
v1.Get("/x", h) // late is fine; sees v1's environment, without auth
This differs from Fiber, where registration time decides. The migration note ships with the release.
Middleware wraps; operations answer. Doctrine, enforced by validation:
a Handler may not terminate a request. Use(static) is refused — register
it at its address: app.Get("/assets/*", h). Use cannot say where a
handler answers; that is what operations are for. Middleware with nothing
beneath it in the subtree is refused, naming its call site.
The diamond is legal. One definition, two references, two occurrences, two prefixes, possibly two environments. No backend may veto a composition the language permits.
IDs. An operation's ID is the dot-joined prefix path plus its name:
v1.billing.invoices.list. Names default from method and path; the ID is
a function of position, never of composition order. After derivation, a
collision means two different definitions claiming one name — refused,
with both trails. Before derivation, the diamond colliding with itself is
expected and resolved by the rule. MCP tool names, SDK symbols, and CLI
commands all use this rule; agents and humans see one name everywhere.
Descriptions. Prose comes from the op declaration (a description field, as in OpenAPI's summary) and lands in every backend alike. One source. Prose written anywhere else about this API is the drift bug this language exists to kill.
An id the author wrote down (v1.24.2), and the verb that was not needed (v1.24.3)
A declaration the programmer wrote is not something composition gets to
overrule — that rule stands, and it is what fixed the operationId defect.
v1.24.2 applied the same reasoning to ADDRESSES and got a verb out of it,
App.Shadow(). That was wrong, and v1.24.3 removed it: an address answered by
one registration and declared by another is a braided program, and the answer to
a braid is to unbraid it, not to give the framework a verb that holds the two
ends together. Use and Group are enough.
A declared operationId survives composition, verbatim
occurrenceID used to qualify EVERY id with its occurrence's prefix, including
an id the author had written with WithOperationID. Including
github.com/hanzoai/o11y under /v1/o11y therefore renamed all 353 of its
published ops to v1.o11y.<id> — every MCP tool an agent had cached, every
OpenAPI operationId, every CLI command, every generated SDK method — as a
silent side effect of one wiring line. The table at "Six decisions" above has
said operationIds | untouched since Graft; the walk had stopped obeying it.
The rule now asks one question first: did the author write the id down?
| id | reason | |
|---|---|---|
declared (WithOperationID) |
verbatim | it is a published name, and a service embedded at a prefix publishes the same operations it publishes standalone |
| undeclared | <prefix>.<method+path> |
there is no promise to keep and there IS an ambiguity: one definition included twice is two operations, and a document cannot hold two under one id |
Where they meet — a definition that DECLARES an id and occurs TWICE — is a
refused program, named by derived with both occurrences and both fixes (drop
the declaration and take the shape-derived id, or include the definition once).
Silently renaming was the old answer, and renaming a published name to resolve
an ambiguity the author never saw is the defect, not the fix.
One address, one registration — and that registration both declares and answers
Two registrations at one address is a refused program, and it stays refused. It has no escape hatch, no scope that declares the collision away, no verb.
App.Shadow() was that verb for exactly one release (v1.24.2) and was removed
in v1.24.3. It existed to let an op be DECLARED in one place and ANSWERED in
another — a service publishing a table of its own operations onto the router its
implementation already serves on, a test registering a second handler at an
address on purpose. Both are the same braid: a declaration and the handler that
satisfies it, written apart, with a framework verb holding them together.
The fix is to unbraid, not to accommodate. The declaration and the handler that answers it are ONE registration:
// no
svc.Post("/roles", createRoleHandler) // answers
published.Post("/roles", createRoleOp) // declares — a second claim
// yes
zip.Post(svc, "/roles", createRole, zip.WithOperationID("CreateRole"))
A typed registration IS both halves — the handler the router installs and the op
the document, MCP tool list, CLI and SDK all read. There is nothing to keep in
sync because there is only one thing. Use composes definitions and Group
scopes them; between them any shape that needed Shadow is expressible, and
each address still has exactly one claim on it.
Validation errors
Accumulate, with trails: conflicts, cycles, inert middleware,
post-derivation ID collisions — joined with errors.Join, each carrying
call sites. Never fail on the first. Suspicion that cannot be a rule
(middleware after an App on the same receiver: intentional co-located,
latent bug cross-scope, structurally identical) lints in vet; semantics
stay expressive.
Removed, and why
Graft, Mount, Attach, and two more verbs: five verbs across four metaphor domains compensated for registry mutation at compose time; the walk made the registry a backend, and the verbs had nothing left to name. Per-kind wrappers and the ref type: every wrapper held only a call site, and every entry holds one, so one envelope replaced them all. The Occurrence type as public artifact: the language exports no IR — no Occurrence type, no stored model, no cached contract. The walk's flattened result exists, unexported and non-semantic; what died was its claim to be part of the language. The tree is canonical; the slice is implementation.
The test behind every deletion: a concept may go if removing it loses no semantic distinction the system depends on. Everything above survived it.
Zip: the runtime
Depends on zip-language.md. The language does not depend on this.
A program describes an application; a host runs one:
host := zip.Serve(app, ":8080") // build, validate, freeze, serve
host.Include(payments) // live change: next generation
host.Drop(users)
host.Reload(billingV2)
host.Close()
Include, Drop, and Reload are host verbs, not App verbs. Use
extends a program; Include publishes a new generation of a running one.
Different worlds, different receivers.
Freeze
Definitions freeze at first inclusion in a built generation. Mutating a
frozen App panics at the call — the stack trace is the locality. Use
against a served tree panics for the same reason: a silently recorded
entry that activates on someone's later Include is the worst outcome, so
the hole does not exist.
A shared definition is immutable everywhere. Changing a shared
subsystem means building a new version and Reloading it at each host
that wants it. There is no cross-host transaction, no process-level server
registry, no lock ordering — those were machinery for reaching into shared
subtrees from an App-anchored Include, and moving the verb to the host
deleted the reach. Hosts on different versions mid-rollout is ordinary
deployment reality, not a hazard this runtime papers over.
Generations
The program is immutable per generation; a host is an atomic pointer to the current one. Change means: build the next generation, validate completely (the walk's first pass), swap only on success. A bad change cannot take down routing — the build fails with trails and the old generation keeps serving. Requests pin their generation on arrival; no locks on the hot path; drained generations are collected. Each generation retains its walk result; reducers serving the live generation — the OpenAPI endpoint, the MCP list, llms.txt — reuse it rather than rewalking.
Inspection
host.Registry() reflects the live generation. Before any host exists,
Registry(app) recomputes per call and is not goroutine-safe — mutable
phase, caller's problem. Registry and Declaration panic with the joined
validation error when the program does not compose — validation runs
before any rendering, so no tool can emit an empty document and exit 0.
CLI entry points recover, print the errors, and exit 1. Panic is
transport there, never presentation.
Listen
app.Listen(addr) is Serve followed by host.Wait() — one line of sugar
over the same mechanism, not a second way to serve. It blocks and hands
back no host, so a program started that way cannot later be changed:
log.Fatal(app.Listen(":8080")) // fixed program, no handle
host, _ := zip.Serve(app, ":8080") // changeable program, handle
The difference is deliberate. A main whose program is fixed wants the terminal form; anything that loads at run time wants the handle, and there is nothing to migrate between them because both install generation 0 through the same build.
Escaping to the router
app.Fiber() returns the CURRENT generation's router. A generation is a
projection, so the next build materializes a fresh one — anything
registered on the value Fiber() handed out would be discarded. That is
refused rather than dropped: a rebuild that finds routes it did not
build panics, naming the count and pointing at app.Use / app.Get.
A silently lost CORS policy is the failure this design exists to
prevent, and Fiber() was the one door still open to it.
Removed, and why
The building exemption: protected no reachable legitimate path; its only
effect was admitting a Use that raced a build. Deleted; freeze is
unconditional.
The process-level live-server set and the multi-server transact clauses
(reachability filtering, creation-order locks, all-or-nothing cross-host
commitment): existed only because Include was anchored to Apps. Host
anchoring made the question they answered unaskable. The old guarantee's
own text conceded cutover was never simultaneous; explicit per-host
reloads are the same reality without the machinery.