mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
main
39
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5085333c13 |
Keep every existing op's wire and close the new kinds' edges
A JSON answer keeps fiber's application/json; charset=utf-8 unless the op names another media, and every declared status carries the Out's schema again, a union's alternative filed under the status it states. A value of the op's own type stating status zero is refused as before; only Body and Redirect read zero as the op's own. A header field is a CLI flag under its field's name as it was, and a 3xx with a body is an answer the remote CLI prints. form: tags bind a form only when the op consumes a form media type, so another binder's tags no longer move an op off JSON or refuse a GET. A cookie: field is set by the request's cookie and nothing else, and no body schema lists it, so a cross-site body cannot fill it. Sse.Send takes a ctx that ends with the stream: a tool call collects for at most toolWait and toolBound bytes, then cancels the producer, and a stuck producer cannot hold the call. A panicking Reader ends its answer, not the process. A socket op refuses a request that cannot upgrade before its handler runs, over REST and the call plane, and a nil CheckOrigin is same-origin, refused 403. The call plane carries a union's JSON alternative; Sse and Socket fields that cannot cross say so with zap:"-". MCP bytes are bounded and untyped bytes take the op's media. |
||
|
|
999e2153b3 |
Type every wire: bytes, forms, streams, sockets, redirects, unions
A typed op could only take one JSON value and answer one, so uploads, signed webhooks, form posts, YAML and merge-patch bodies, PDFs, SSE, WebSockets, OAuth redirects and upstream relays stayed untyped routes with no schema, tool, command or SDK method. Each is now a typed op and its REST wire is unchanged. Requests: a zip.Body field takes the body and Content-Type exactly as sent; an In implementing Parser decodes itself; form: fields and *zip.File parts bind url-encoded and multipart forms; cookie: binds a request cookie as an in: cookie parameter. Consumes names the media. Answers: zip.Body (bytes, filename, its own status and headers, a Reader streams chunked), Verbatim[T] (a relay documented as T), Sse[T] (events with keep-alive), Socket[M] (an upgrade after the contract runs; 426 without one, 501 over ZAP), Redirect, CookieCoder, Produces, and unions: a type whose OneOf method's results are the alternatives, with Or[A,B] as the stock one filing each under the status it states. Projections follow from the manifest: the document publishes binary media, form schemas, x-events, x-socket with 101, 3xx with Location and oneOf; MCP takes bytes as base64 and returns bytes, text or collected events as tool content, listing a socket op in _meta as no tool; the CLI reads --body @file|-, --field name=@file and --out, prints events as they arrive and bridges a socket to stdin and stdout. tools/list is ProjectMCP over the manifest, the projection MCPTools already was. Prose reaches promoted fields (filed under the declaring struct), a type defined over another struct, and the type arguments of zip's kinds and OneOf results; an instantiated generic is published under its name followed by its argument names instead of an illegal component key. The unused reflect-only document and tool builders go. |
||
|
|
18018da2ab |
An interface field is any JSON, not an object
schemaOf fell through to {"type":"object"} for an interface, and the
projector did the same for the `any` prim, so a field holding a token
string or an array was described as an object. The published examples that
carried one failed against their own schema — Hanzo's auto.runIn.auth and
tool.toolCall.arguments — and every generated client typed the field wrong.
An interface is now the unconstrained schema a silent marshaler already gets.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
fb2b7f6d5c |
zip declares its ops in Rust, and projects them with the Go one's projector
A Rust developer writes Rust handlers, with Rust types and Rust doc comments,
and gets every projection from that source — the same deal a Go developer has
had. No .zap is written by hand and no Go program declares a Rust service's
ops.
The split falls on a measured seam. What is native per language is DECLARING an
op and RUNNING it; what is shared is the projection logic, driven from a neutral
description of the registry.
Manifest the registry with the Go taken out: ops, types, fields, wire
names, prose. App.Manifest fills one in by reflection; a
proc-macro fills one in as a Rust crate compiles.
Project* the OpenAPI document, the MCP tool list, the CLI and the ZAP
IDL, read off a Manifest. Go's own three now go through them —
the whole existing suite is the oracle — so Go keeps no private
path for them and the other languages cannot rot.
cmd/zipc the build-time projector. It reads a manifest, or the fragments
a compiler wrote, and writes the four documents.
rust/ zip in Rust: #[zip::ops] on an impl block, #[derive(zip::Wire)]
on a type, a router, a JSON binder, an HTTP door and a ZAP door.
The wire is generated by zapgen from wire.zap; nothing here
spells a byte offset by hand and nothing links Go.
The layout rule moved out of the manifest and into the projector, so the offsets
in a .zap are derived once for all three languages rather than three times;
TestProjectZAPMatchesRegistry pins that derivation against the layout the Go
encoder actually writes.
One service, written twice — corpus/info in Go and rust/examples/info in Rust,
fourteen ops each — is the gate. The OpenAPI document, the MCP tool list and the
CLI command tree come out byte-identical, and the ZAP schema agrees on every
struct, type and offset; it differs only where a field carries the declaring
language's own case convention and where the ledger names a type the way the
person who must change it would grep for it.
|
||
|
|
46a0a196f6 |
Declare each operation's refusal body as its default response
Every non-2xx a typed op answers is an RFC 9457 problem document, or RFC 6749's error pair at an OAuth address, and the document declared neither, so SDK generators typed errors as unknown. Each operation now carries a default response that points at a problem-details or an oauth-error schema. The OAuth addresses come from composeOAuth over the same walk the error handler is built from. Neither schema name can be a Go type's name. Neither schema writes a boolean additionalProperties: CommandsFromSpec parses schemas as objects, and a boolean failed it. TestTheDocumentDeclaresTheRefusal checks the declaration at both kinds of address, and that the refusal on the wire carries every member the schema requires. The ErrorHandler and MarshalJSON comments still described the old envelope and a written instance member; they describe what is written. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
88b93b8fae |
zipgen reads the schema it was refusing to project from
The registry a projection reduces over came from Go declarations, so zipgen — which links no service — had an empty one, and every SDK, MCP server, CLI and docs page it wrote was empty. It refused rather than write those, and named a missing connection: a .zap schema, read into a registry. zip.ReadZAP is that connection. It parses with github.com/zap-proto/go/idl, which is the parser cmd/zapgen has always used and which is a package rather than a command as of that repository's v1.8.3; there is one grammar, not a second one written here to agree with the first until it did not. It answers with one app per declared interface, each carrying one op per method, which is the exact inverse of ZAPSchema taking any number of apps and writing them as one interface each. A file read and projected back is the same text, and there is a test that closes that loop over this package's own output. Two things stood between a declaration and an op. Go makes no named types at run time, so a struct built from a declaration carries the name it was declared under in a decl tag on its fields, and typeName is now the one place any projection asks a type for its name — without that, Ping and Pong of identical shape intern to a single reflect type and a method declared to answer Pong answers Ping. And a .zap field states an offset while LayoutOf derives one: every field is compared and any disagreement is refused, naming all of them. That refusal earns its place immediately, because the IDL assigns offsets by packing and this layout aligns each slot to its own width, so a hand-written schema with an i32 before a wider field describes a wire this process does not speak. An op read from a schema knows what it takes and what it answers and refuses to run, because a schema states a contract and the implementation of it is somewhere else. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
478e06d1d4 |
Authorization can hold an op, not only allow or refuse it
The hook answered allow-or-error, so an operation that needs a person to sign it off had nowhere to go but a refusal. A caller told 403 stops; a caller told the request is held waits, and those are different futures. An Authorizer now answers a Decision — Allow, Deny or Approve, with the clause that reached it and the reason a person reads — resolved ONCE in the run() contract that op.invoke and op.direct both funnel through. The projections only render it, so all six agree without a branch in any of them: REST answers 403 or 202, the call plane the same two over ZAP, MCP carries the held body as content rather than an error because nothing failed, the CLI prints it, the graph reports it beside the field it was selected for, and a Go caller reads it off the error lane with HeldOf. An Approval has no Out and never will have one — the handler did not run — which is the whole reason it does not travel the value lane there. Clause, not rule. This package already spells an Authorizer "the rule" — App.rule, adopt, "two rules that disagree" — so the token naming which part of it fired needs its own word, and a rule has clauses. The error return survives for a genuine failure of the CHECK: an authority that cannot be reached has not decided anything, and answering 503 rather than 403 is what lets a client retry. The op carries the question rather than the app, because they differ. A composed child answers to whatever included it, and a mounted op has no local rule at all — so the document publishes the held body exactly where the seam would produce one, and a gated service's OpenAPI reads as the pair a generated client needs. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
33ebd6abca |
Prose is filed under the package that declared it
The documentation map is one map for a whole process, keyed by an address — and an address is unique only within the app that serves it. A node runs twelve chains on one binary and the P-Chain and the X-Chain both answer GET /height, GET /block, GET /tx, GET /utxos, GET /balance and POST /tx: six addresses where whichever init() ran last published its prose for the other chain routes. Nothing failed. The document and the MCP tool carried the wrong sentence for the right route, which is why it survived every test until someone read the output — and renaming a path to dodge it would have been the wrong repair twice over, since the mount already tells the chains apart and every future chain would inherit the dodge. So the key carries the declaring package, read off the handler itself at registration. It is the namespacing the schema registry already does for named types: who declared a thing is a property of the code. An unqualified key is still read, so a generated file written before this is still correct for the single-app process it was written for. |
||
|
|
42e50d17b9 |
A record's leaves are named through it, and a URL can write them
A pagination cursor is one field holding two — an address and a utxo id — and a URL had no spelling for it, so a read whose input nests a record could not be served on a safe method at all. The choice was a GET that silently dropped the cursor or a POST, and a POST is a read that has to be authorized. A leaf is written under its record's name: `?startIndex.utxo=`. Only under it — a bare `?utxo=` still binds a top-level utxo and never a nested one, so an input that nests a record names its target explicitly and an authorizer reads the target the caller wrote rather than one guessed out of a sub-struct. urlFields walks the same way, so the document publishes every dotted name the binder fills and no name it does not. A struct with a written form of its own is not a record here: a time and an id are one word in a URL, and urlSchema already says so. A pointer record is allocated only where the URL named something inside it, so a cursor nobody sent stays absent rather than arriving empty. The descent stops at the keys a caller wrote, which is what bounds it on a self-referential input. |
||
|
|
3fff376dfd |
A repeated argument rides a URL, and the document names what the binder fills
Two halves of one rule, and the second is what made the first safe to rely on. A URL carries one value per name, so a repeated argument is written as one value with commas — style: form, explode: false, the spelling the document already publishes for it. The binder had no case for a slice, so an op taking a list of ids answered 200 about an empty list. Each element is written by setScalar, so a list of ids says nothing about ids a second time, and a []byte is not a list here: encoding/json spells it as one base64 string. urlFields had the kind switch written a SECOND time, and the second one drifted the moment setText taught the binder to read an id. The binder filled a parameter the document did not name, which is the same disagreement as before with the sides swapped — a caller reading the document had no way to know the argument existed. Both now read urlSchema, so what is published and what is bound are one answer. A type that reads itself from text is a string there, whatever it is made of: describing an id by its 32 bytes published an array of integers for a value nothing spells that way. |
||
|
|
4b3ce47431 |
A number in the document states its width and its sign
"integer" does not say whether a field is one byte or eight, or whether it is signed. JSON survives that ambiguity because a number is self-delimiting, and the document was written when JSON was the only thing generated from it. It is not any more. One of the clients generated from this document speaks ZAP, where a field IS an offset and a width — so a generated type that reads a uint32 as an int64 does not merely mis-print a value. It takes eight bytes where the service laid four, and every field after it is read from the wrong place. The failure is silent and total, and it starts in the document. The names are OpenAPI's own where it registers them (int32, int64) and the obvious extension where it does not. |
||
|
|
413d9863cb |
A type that writes its own JSON can state its own shape
schemaOf refuses to describe a marshaler by its fields, and it is right to:
the fields are not the wire form. json.RawMessage proved that. But the only
thing it could then say was `{}`, and `{}` is true and useless — every such
field reaches a generated client as an untyped value.
time.Time was the exception, hardcoded here, because its output shape is
documented. The generalisation is to let the type say it. The bytes and the
schema are the same fact about the same type, so they are declared together,
next to MarshalJSON, rather than guessed in this package or kept in a table
this package would have to maintain.
type SchemaDescriber interface{ JSONSchema() map[string]any }
Checked on the value and on the pointer, since MarshalJSON is usually written
on the pointer. A nil answer is not a shape and falls back to `{}`. The map is
copied before it is returned: the document is assembled by mutation, and a
shared map would leak one field's description into every other field of that
type — the test asserts exactly that.
The case this exists for: Lux node's platform and xvm replies carry 236 fields
whose types write their own JSON — quoted decimals (a JSON number is a float64,
so a uint64 above 2^53 does not survive one), ids, encodings. All 236 publish
as `{}` today, so the document is not yet worth generating an SDK from. Those
types can now say `{"type":"string","pattern":"^[0-9]+$"}` once, at the
declaration, and every field of that type is described everywhere.
time.Time keeps its existing answer, and a marshaler that states nothing keeps
`{}`. Full suite green.
|
||
|
|
e30283cb05 |
A + capture is +1, and fiber counts each marker on its own sequence
pathParams named every wildcard's router key "*N" off one shared counter. Fiber does neither: it keeps the marker in the key and counts each separately — measured against the router, `/p/+/q/*` matches under `+1` and `*1`, and `/p/+/q/+` under `+1` and `+2`. So a `+` route's capture was named "*1", which binds nothing. Both halves of the previous change then failed on it at once: the field fell through to the query list, because the suppression looked under a key fiber does not use, and the path variable stayed undeclared. One wrong character, both symptoms. The document's name is a SEPARATE sequence and stays positional, because that is what Template writes into the path and what a declaration is looked up by. The two counters are why a pathParam carries both names rather than deriving one from the other. TestTheRouterKeyIsFibersOwn reads the keys off the router for six patterns — a fact about fiber, not about zip, so it is measured rather than argued. |
||
|
|
3abb36a5b2 |
A parameter's prose comes from the field it binds, not from its URL name
zipdoc files a field's description under the name it carries on the WIRE; a path parameter was looked up under the name the ROUTER matches on. The two coincide for every `:name` segment and differ for a wildcard, whose router key is fiber's *N — so the one parameter that IS the address was the one parameter with nothing said about it. urlField carries the wire name beside the URL name and docKey joins them, so the lookup asks under the name the prose was filed under. The example lookup keeps asking under the wire name it was always keyed by: the two are separate arguments now rather than one doing both jobs, which is what made the first attempt at this silently drop every parameter example. The description is not asserted in this package's tests and cannot be — zipdoc indexes declared source, not _test.go, so a fixture here carries no prose whatever the lookup does. What is asserted here is the half that can be: the wildcard is declared as a path parameter and its router key does not leak into the query. |
||
|
|
9db2563483 |
Declare the wildcard segment, do not only spell it
Template names a wildcard {wildcardN} and the document builder writes that into
the path, but colonParams — which derives the parameter declarations — read
`:name` alone. So the path carried a template variable nothing declared, and the
field binding it was described as a QUERY parameter under fiber's capture key.
A client generated from that has no argument for the address, sends the braces
literally, and is answered 404 by the route it was generated for. Spelling the
segment without declaring it is worse than refusing the op, which is what this
did before.
pathParams replaces colonParams and carries both names a segment goes by: the
document's, which a parameter must be declared under because the path uses it,
and the router's, which is what an input's `url:` tag binds against. Every reader
takes the one it means, so neither can be mistaken for the other — declared under
the router's key the document would name a parameter no path contains; bound
under the document's the input would bind nothing.
It also carries the segment's index, which is the only substitution that works
for a wildcard: remote replaced `:name` by spelling, and a wildcard's segment is
`*`, matching no name.
TestAWildcardIsDECLAREDAndNotJustSpelled asks the document for both halves —
the declaration present, and the capture key absent from the query — because the
missing declaration showed up as the phantom query parameter.
|
||
|
|
f0003b31bf |
The document builder asks Template, instead of translating on the way past
buildOpenAPI carried its own translation — replace "/:" with "/{", then repair a
missing brace by calling Template anyway — so the rule lived in two places and
the shortcut answered for two shapes it was never asked about.
A wildcard has no "/:", so it never reached Template and "*" was published
verbatim: a path no template can mean, and one a consumer reading the route table
names {wildcardN}, so the two resolve as different operations rather than as one
address spelled twice. A path carrying a parameter AND a literal "}" satisfied
the repair's guard without being repaired, and published "{name" unclosed.
Template is the rule and is now the only copy of it; closeColonParams, which was
that name with an extra hop, is deleted. TestTheSpecSpellsAWildcardTheDocumentsWay
asks the DOCUMENT rather than the function, which is the half a Template test
cannot see — fixing the rule and fixing its caller were two changes, and only the
second one shows here.
|
||
|
|
c59a9b9b94 |
Stop naming a verb that no longer exists
Graft was deleted when the op registry became a lazy projection over a walk, but its name stayed in 40 places: eleven test names, a helper, a godoc link to the removed symbol, and prose describing composition as grafting. The rationale those comments carry is still true and still worth reading, so it stays — reworded to describe the old design rather than to name a symbol a reader cannot look up. graft_test.go is nest_test.go; its helper serves an app in memory and is called probe, which is what distinguishes it from get (a real socket) and from wireGET (package zip, not zip_test). One comment also described a pathless app.Use as an authentication seam. It is authentication; the extra noun said nothing. |
||
|
|
3ab8d5e9be |
the version every address shares is not part of its name
/v1 is on 2,026 of this estate's 2,473 operations, which is to say it tells none of them apart. A segment earns its place in an id by distinguishing two operations; this one is a constant, and it rode four surfaces at once — the OpenAPI operationId, the MCP tool name an agent reads on every turn, the by-name call key, and the CLI's spelling — so the constant was paid for four times, most expensively in a tool list where every token is context not spent on the question. ID drops it, and only it: the leading segment, only when it is the default. A later version is the EXCEPTION and keeps its segment, which is both how it stays honest and how it stays unique — get_agents is /v1/agents and get_v2_agents is /v2/agents, so the two cannot collapse onto one name the walk would then refuse to publish. The default is implicit; a departure from it is not. The pinned names in the suite move with the rule, because they were pinning the rule. TestServePathAllocsAreChainDepthInvariant fails here and on the base commit alike — 34 allocs either way, unrelated to naming. |
||
|
|
8687aa1bb3 |
one rule for an operation's name, and it is the published one
An operation's id was derived twice, by two rules that disagreed.
zip spelled it dotted(prefix) + "." + method+path-with-the-punctuation-
dropped, so POST /v1/agents/targets declared in a Group("/v1/agents") was
v1.agents.post_targets. cloud's openapi package spelled it from the
ABSOLUTE path with a parameter written "by_<name>", so the same operation
was post_v1_agents_targets. Both reach the published document — cloud's
Fold hands a typed op's own id straight through — so openapi.yaml carries
BOTH spellings depending on whether a route happens to be typed, and an
agent that read the document and asked for the tool it named got "no such
tool".
The document's rule is the published one (every generated SDK method,
every docs entry, the hanzo command tree), so it is the rule that stays.
zip moves to it:
- ID (exported, replacing defaultOpID) writes a parameter "by_<name>"
and sanitizes a segment to [a-z0-9.-]. '-' and '.' survive because '_'
encodes '/', and folding them collapsed /v1/pricing-policy onto
/v1/pricing/policy once already. Every spelling of one parameter
reaches one name: ":id", ":id?", ":id<guid>", "{id}", "*".
- occurrenceID derives from the ABSOLUTE path the occurrence answers at,
not from a dotted qualifier built out of the composition's shape. Both
invariants hold: a DECLARED id still survives composition verbatim,
and two inclusions of one definition still get two ids — they have two
absolute paths — with nothing positional about it. What changes is
that a group-structured app now derives the SAME id as the flat one,
which is the whole point: the id is what the address says.
dotted is deleted; it had no other caller.
Exported because cloud's openapi package held the identical rule as a
second copy. One rule, one place — cloud now calls zip.ID.
Also retracts a figure I published in
|
||
|
|
ed9616933e |
an op's address is a value its input already carries
zip.Address is bindURL read backwards. registerTyped binds the segments the
router matched onto the op's In, so by the time a handler runs the In IS the
address's parameters, named and decoded. Anything that has to rebuild the
concrete path — a relay handing the call on to something that speaks HTTP —
otherwise spells the address a SECOND time by hand, and the hand-built one is
what gets requested, so the route pattern it drifted from is never consulted and
never contradicted.
It is deliberately the inverse and not a new rule: same field walk (wireFields,
so promotion counts), same naming (urlFieldName, so `url:` beats `json:`), same
case-insensitive match. The tests bind a value in and read it back out rather
than comparing against a table someone wrote down.
zip.Template is the same address in the spelling a DOCUMENT writes — ":traceId"
-> "{traceId}", with the router's own business (a constraint, an optional
marker) dropped, because those say how a segment is matched and not what it is
called. closeColonParams was already that function under a private name and now
delegates to it, so the document's spelling has one implementation.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
889cf8aced |
an answer states its response headers, and Host already binds
The third and last gap holding Bridge open. Symmetric to the status fix: the ANSWER states its headers, declared on the op and valued by the returned value, so there is no mutable slot on the context. A slot is invisible to every projection, which is how a browser session cookie came to be set by a route no projection described. WithResponseHeader declares the names; HeaderCoder on the output supplies the values; an undeclared name is refused as an error rather than written. Every declared header lands in the document under the response, because a header a caller relies on — a cache directive, a payment challenge, a Set-Cookie — is part of the contract and Set-Cookie is a security-relevant part of it. Transport meaning is DEFINED rather than silent: written wherever the op owns the response — REST, and the by-name call plane, where one request carries one op — and not written over MCP, where one HTTP response carries a whole JSON-RPC envelope that may hold several results. The declared set still reaches the tool schema and the answer's value is identical everywhere. Tested. And Host needs no new spelling: header:"Host" already binds, because fasthttp surfaces Host through the same accessor. Measured, not assumed, and pinned by a test so cloud can convert its 2 sites as they are. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
08da1b6858 |
an operation can state its request facts and its success codes
Bridge exists in cloud because zip lacked two vocabularies. Both close here, and both close the same way: the fact rides the op's CONTRACT, so every projection sees it. A replacement that were equally invisible would not be one. Gap 1 — a header tag. `Tenant string \`header:"X-Tenant"\`` binds off the wire with no middleware anywhere, and appears as a header parameter in the document, a flag on the command, and a property in the tool schema. Binding order is body, then headers, then URL: the URL is the addressing authority. On a transport with no HTTP request — the CLI, and the call plane — the answer is nothing rather than a nil panic, which is the shape CallerOf already established. Gap 2 — WithStatus takes a SET, and the answer states which one it is via StatusCoder. The status is a property of the value the handler already returns, so there is no mutable slot: a slot is a side channel and a side channel is what Bridge was. Every declared code reaches the document, so a client has a branch for each. A code the op did not declare is refused as an error — not a panic, because it is a runtime value on a serving goroutine. Also: App.Routes and App.Test now exist. Router's comment recommended both while neither did, and they are the two things callers reached through Fiber() for — so the stale reference is fixed by making it true rather than by deleting it. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
3407863fb5 |
the walk is a visitor; the occurrence type is gone
walk(app, visit func(n node, ctx scope, site callsite) error). The callback's parameters WERE the payload, so the struct wrapping them was a noun invented to carry what three arguments already carry — and retaining the slice made every backend a consumer of a materialised intermediate rather than a reducer over a traversal. The English word survives in prose because the definition/occurrence DISTINCTION is load-bearing; the type does not. Validation stays its own first pass (verify), so cycles and conflicts complete with trails before any backend sees a visit. A generation retains what was REDUCED — router, ops, host set — never the traversal. Also: #4 verified, not changed. Own-op tool names already ride the operation-id rule because they read Registry(); a plugin catalogue tool is invoked by NAME at the plugin's own door and never consults a prefix, so ONE definition is ONE tool and namespacing it would invent a callable that does not exist. Tests pin both. #2: Drop and Include share one transact path, so the guarantee is identical, and the lock discipline that makes it deadlock-free is now written down with the two invariants it rests on. Documented honestly: commitment across servers, not simultaneity. Godoc: every bracket reference to a deleted symbol rewritten. LLM.md carries the canonical three-document spec. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
7dc45e8b75 |
compose: one verb, because the eager registry was the disease
An App becomes a PROGRAM: an ordered list of entries, each carrying one of exactly three payloads — middleware, a route, or another App INCLUDED BY REFERENCE. One walk flattens it into occurrences in preorder; every projection is a reducer over that slice. Graft, Mount and Add delegate to the same walk. Five verbs existed because the op registry was a FIELD that composition appended to at call time, so composing two apps meant merging two registries and AdaptNetHTTP could destroy five projections by type-erasing an *App. A projection over a walk needs no merge rule. Prototype only: not for main, not tagged, not released. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
926fa0b088 |
graft: compose an App in process, so the child's registry survives
The only way to put a live *App behind a host's router was app.All(prefix, zip.AdaptNetHTTP(child)). AdaptNetHTTP takes an http.Handler and returns a closure, so the App went in and a bare function came out — and the child's op registry went with it. All five projections die at that closure: the OpenAPI document, the MCP tools, the CLI commands, the by-name call plane and the Declaration. A host could publish only the wildcard it hung the closure on. hanzoai/cloud publishes five path keys for an identity provider holding 94 typed ops. Graft is the fifth verb. Listen serves here; Mount delegates there; Add composes a registrar; Load composes a binary; Graft composes an APP. Delegation, not route-copying — the load-bearing call. Copying the child's route handlers drops its middleware (Declaration projects GetRoutes(true), which filters Use out), and a service whose whole authentication seam is app.Use(guard) would arrive with the guard gone. Copying the Use entries instead is worse: a pathless Use grafted onto the parent gates every route in the host binary. Delegation has neither failure, because middleware stays inside the app that declared it. It is also cheaper than the adapter it replaces — no net/http round trip, so the ~5% and the dropped fasthttp user-context both go — and it NARROWS the surface: only the patterns the child declares, so an undeclared path falls through to the parent instead of being swallowed. Paths and operationIds are untouched: op.Path is already the whole absolute path, and an id is a published SDK method name. The registry gets a COPY of each op carrying Origin, so composing a child never edits what the child says about itself, and op.invoke still closes over the CHILD's authorizer. Schema names are qualified <origin>.<Type> UNCONDITIONALLY. Qualifying only on collision makes a published type name a function of who else is in the room. One more input to schemaRegistry.nameFor, which already qualified by package — not a second naming scheme. An app with nothing grafted is byte-identical to before. The refusal is all-or-nothing and reads the ROUTER, not the document: the addresses that collide in practice are liveness paths no document publishes. Graft refuses after prepare, because the document, the tool list and the call plane render once — an op grafted later would serve and never describe, which is the defect this removes. AdaptNetHTTP stays: it is still right for a genuinely foreign handler. What dies is AdaptNetHTTP applied to a *zip.App. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
00db0cad1b |
openapi: an embedded struct's fields are on the wire, and no projection said so
encoding/json PROMOTES an embedded struct's fields onto the outer object. Four
things asked "what fields does this type carry" and each answered with its own
loop over NumField, which is not that question — and an embedded body type is
very often UNEXPORTED, because it is an internal detail two routes share. So all
four skipped it on IsExported and dropped every field it promoted:
the schema published an object with only the outer fields;
the document declared NO request body at all when the outer fields were all
path params, since "every field is a path param" was true of the fields it
could see;
the CLI offered no flag for them, so the command could not send a body;
bindURL could not bind one from the URL.
The decoder never had the bug. The wire took those fields the whole time, which
is exactly why nothing failed — the four projections were describing a request
shape the service does not have, and a generated client would have had no way to
send the body at all.
wireFields is the one function that knows the promotion rule, and the four read
it. It answers STRUCTURE only — which fields exist, the outer type's and the
promoted ones together. WHICH HALF of the request carries a field stays with
`json:` and `url:`, asked by each caller, because a field can be URL-only
(`json:"-" url:"script"`) or body-only (`json:"script" url:"-"`) and a filter in
one place would silently drop one of the halves. A tagged embedded struct is a
named object rather than a promotion, which is encoding/json's rule and so this
one's too; an embedded pointer is not followed, because a nil one has no fields
to promote and a projection must not describe what binding cannot reach.
Found by declaring a real route on v1.18.9 — cloud's PATCH
/v1/admin/catalog/providers/{name}, a path param plus an embedded unexported
patch body — and READING the artifact rather than trusting it. It published no
requestBody. embed_test.go pins all four projections plus the wire, and fails on
the first of them if the rule regresses.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
cc2d647ed0 |
openapi: four things the document said that were not true
Each of these shipped into openapi.yaml and therefore into every generated SDK, and none of them could fail a build, because a wrong description is not an error. A PHANTOM REQUEST BODY. hasBody asked only the method. So POST /things/:id/verify, whose whole input binds from the path, published a requestBody marked required whose one property WAS the path param — every generated client gained an argument the caller must construct to repeat a value it already passes in the URL. Ten instances were live in the fleet's published subsets. hasRequestBody refines hasBody with the clause that was missing: the input must have at least one field the URL does not already carry. The wire is untouched — bindURL binds the path last, so a body repeating a path param never won anyway. A TYPE DESCRIBED BY ITS PARTS INSTEAD OF ITS WIRE FORM. schemaOf reached the custom-marshaler rule only inside the struct case, but the rule is about the MARSHALER. json.RawMessage is a []byte whose MarshalJSON emits raw JSON, so it fell to the slice rule and published as an array of integers — for relay ops whose answer is frequently an array or null, the document asserted a shape it can never have. time.Time fared worse: all its fields are unexported, so it published a $ref to a schema with no properties, leaving every timestamp in every SDK undescribed. The marshaler check now runs first, with time.Time named as the one marshaler whose output shape is documented (RFC 3339, format: date-time). A plain []byte still reflects as a base64 string, because it has no marshaler. A SUMMARY WITH A LINE BREAK IN IT. A doc comment wraps in Go source; a sentence ends where the next begins, whether that is a space or a newline. The summary is neither fact — it is one sentence on one line, in the spec, in CLI help, and in the first docstring line of every generated SDK. sentenceEnd now accepts a break as a terminator and the result is re-joined on single spaces. A ROUTE THAT COULD NOT BE DECLARED AT ALL. bindURL matched on the json name, so a path parameter and a body field that are the same word for two different things (a worker's name in the path, its source in the body) collided: the body field was silently overwritten by the path value, making the route structurally untypable. Fields now name themselves for the URL with `url:` and opt out with `url:"-"` — one tag answering one question, separate from what `json:` answers. The CLI reads the same tag, so the opt-out gains a flag rather than losing one. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
5dfa4ef616 |
plugin: a repo declares its routes, and the host discovers them
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]> |
||
|
|
ff7fe451d6 |
typed: WithStatus — a 201 the document can see
zip wrote 200, or 204 for a nil Out, and had no vocabulary for anything else. A service that answers 201 Created therefore had to reach around the framework and set the status per request, from inside the handler. That works on the wire and nowhere else: the status is a CONTRACT detail written into a side channel no projection can read, so the document says 200, every generated SDK says 200, and the route has always sent 201. It is the same failure as a query parameter's required-ness being invisible — a contract detail that exists only at run time is not a contract. So the status is declared on the op, where the registry can project it: zip.Post(app, "/v1/things", create, zip.WithStatus(201)) op.Status keys the document's responses object, so a client generated from it expects what the service sends. The schema rides along unchanged — a 201 describes its body exactly as a 200 does — and an op that declares nothing keeps the old defaults byte for byte, so no existing document churns. HTTP-only by construction. The REST route and the document read it; the MCP tool, the CLI command and the op-call plane are untouched, because each carries its own outcome. A void op keeps the status it declared rather than falling back to 204: the declaration is what the caller was promised. A non-2xx panics at declaration rather than failing at request time. An error status is the error a handler returns (ErrNotFound and friends); letting a declaration state one too would be two places for one fact, free to disagree. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
8f6dcca49e |
wire: a bodyless method, spelled once — DELETE stops reading a body
BREAKING, on purpose. hasBody called itself "the ONE place that rule lives" and
was the only site that called it; three others re-stated it differently and
DELETE had drifted:
- typed.go read a body for anything but GET/HEAD, so a typed Delete decoded a
request the document says has none;
- cli.go's Remote.Invoke SENT that body, so the CLI's remote path and its
in-process path disagreed about one op;
- cli.go's bindIn offered every field as a flag, including ones no URL can
carry — `--tags '["a"]'` on a DELETE marshalled fine, went out as a query
value, and was dropped by the binder. The command silently did nothing.
All four now call hasBody. A typed Delete takes its input from the URL: path
params and query string, which is what every client generated from that document
already sent. It is a rule about the WIRE, not about the op — op.invoke still
decodes whatever JSON it is handed, because a call addressed by NAME (MCP
tools/call, zip.Call, a command) has no URL to carry half the input in.
Two things a bodyless op lost in the document are closed with it:
- REQUIRED-NESS REACHES THE PARAMETER. urlField.required reads the same
`validate:"required"` the body schema does. Every query parameter was
hardcoded optional, so a field the handler refuses to run without was
described as optional and every generated client made the argument optional
to match — a document describing a call that cannot succeed.
- THE EXAMPLE SURVIVES. A bodyless op has no requestBody for the doc comment's
Example to live in, so it rides the parameters that carry its values and
CommandsFromSpec rebuilds the object from them. Before this, every GET and
DELETE reached the published reference and the generated CLI with no example
at all.
TestCLI_SpecAndRegistryAgree is now plain equality over every field of every
command for every method. The skip is gone, and so is the divergence helper that
replaced it one release ago: there is no divergence left to pin.
Also: a validation failure names the field the way the CALLER named it, through
the same jsonFieldName rule the decoder, the schema and the flag all read.
Someone who sent `reason` was told `field "Reason"` is required — a name that
appears nowhere in the document they were working from.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
72353f9d03 |
openapi: one type, one definition — and a cycle can no longer take the process down
schemaOfDoc took a `registry` map and never wrote to it. Two consequences, one
cosmetic and one fatal:
- no $ref sharing. A type reached by ten ops was inlined ten times.
- NO CYCLE GUARD. `type Node struct { Children []Node }` recursed forever, so
building the document overflowed the stack — and so did listing the MCP
tools. A Go stack overflow is not recoverable, so a service whose input had
a self-reference could not start at all.
schemaRegistry makes the registry real: a named struct is described ONCE and
every use of it is a $ref. The entry is claimed BEFORE the type's fields are
walked, and that claim is the cycle guard — a type that contains itself finds
the definition already in flight. Two packages that both call a type Config no
longer overwrite each other either; the second is qualified by its package
instead of silently publishing one type's fields under the other's name.
The projection owns where its definitions live: #/components/schemas/ in a
document, #/$defs/ in a standalone schema. rootSchemaOf is the standalone form
an MCP inputSchema needs — the root inlined (a tool's schema has to BE the
object) with everything it reaches carried alongside, and no $defs at all when
nothing refers to anything, so a flat tool schema is byte-identical to before.
Three more things stop re-deriving what the registry already knows:
- PATH PARAM TYPES. They were hardcoded "type":"string" while query params
consulted the input type — the same value described two ways depending on
which half of the URL carried it. bindURL is ONE binder over ONE set of
fields, so urlFields is now one list and the document reads it for both.
- FLAG KIND. cli.go's flagType re-derived the type vocabulary from reflect.
It now asks schemaOf what the field IS and maps the answer with specType,
the same rule the spec-derived CLI uses, so a flag spelled from a Go type
and one spelled from that type's published schema are equal by construction.
- COMMAND IDENTITY. commandName computed a different op identity from opName
and ignored WithOperationID entirely: renaming an op renamed it in the
document, the tool list and the call plane while the CLI carried on
spelling itself from the route. One op had two names and no test related
them. Command.OperationID now carries the one token every projection uses,
CommandsFromSpec reads operationId off the document, and an explicit id
renames the command with everything else.
TestCLI_SpecAndRegistryAgree stops hiding the hasBody drift. It used to
`continue` past GET/HEAD/DELETE while the fixture registered no DELETE — the
case was skipped twice over. It now registers one and pins the divergence
precisely: the document names the registry's scalar flags and only those,
declares them optional whatever `validate` says, and drops the example. None of
that is endorsed; closing any of it fails the test and sends you to LLM.md.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
d15ad939f6 |
feat(call): services call services by op name, over ZAP on a unix socket
The typed-op registry gets its fourth projection. An aggregator that reaches
another app today either imports its package — dragging in that app's whole
dependency graph, which is what turns a fleet of small services into one
mega-binary — or hand-writes a client against its URLs, which is the schema
copied into a second place to drift. Neither is necessary: the op already has
a stable name, and now that name is callable.
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 (/.well-known/zip/op/:op), so it rides every
transport Listen was given and inherits the app's middleware, error handler,
validator and Authorizer — exposed exactly as much as the REST routes and no
more. It resolves through opByName + op.invoke, the same pair tools/call runs,
without MCP's agent-facing envelope. Errors cross whole: the wire form is what
errorHandler already writes, so errors.As sees the *HTTPError the callee
returned, status intact. A void op yields (nil, nil).
Generic, not generated, deliberately: a generated per-op Go client would put a
second copy of the schema in the caller's repo on its own regeneration
schedule — the drift this plane removes, one directory over — and would be a
fourth client generator racing openapi-generator, which already builds the
published SDKs from the OpenAPI projection.
ONE socket-path scheme, used by both halves so they cannot drift:
SocketPath(name) = RuntimeDir() + "/" + name + ".sock"
RuntimeDir() = $ZIP_RUNTIME_DIR | $XDG_RUNTIME_DIR/zip | /run/zip
A service serves at zip.Addr(zip.SocketPath("flags")), a caller reaches it with
zip.DialApp("flags"). socketIn states the shape once; the plugin loader names a
child's private socket with the same function. Listen creates the directory
0700 when missing, so the canonical path works on a fresh host.
Identity is never forged, because the caller never states it. WHO the request
is for is the gateway's assertion, forwarded by Ctx.Forward and read by
CallerOf; WHAT is calling is SO_PEERCRED, read by PeerOf — the peer does not
send it, the kernel answers on its behalf. nil means "cannot attest", so a
check fails closed rather than reading a zero Peer as root.
MCPTools() exposes the tool surface the same way OpenAPISpec() exposes the
document, so a host can compose plugins' tools in-process without a round trip.
openapi.go now reads operationId from opName, the one place that rule lives,
instead of re-deriving it inline — all four projections address an op by the
same token.
Cost: Benchmark_TypedRoute/typed 28 -> 29 allocs/op (the one context value that
makes CallerOf and PeerOf possible). The untyped path is unchanged at 27 and
still pinned at one heap value per request.
Proven end to end: a real plugin on a unix socket, a client dialing it, a typed
round trip with no HTTP in the path — the reply frame is decoded with zaphttp's
own codec, HTTP/1.1 text gets no HTTP answer, and the peer credential the
handler observes is proof the transport was unix. Full suite green with
CGO_ENABLED=0 and under -race.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
170e572463 |
cli: the registry is the CLI, and the doc comment is what describes it
One registry, three projections. A typed op already produced a REST route, an OpenAPI path and an MCP tool; it now also produces a command. cli.go derives `<service> <operation>` from the op's own identity, so adding a route can never rename an existing command, and LocalInvoke runs it through the op's own invoke seam — a fused binary needs no client because the command IS the handler call. clispec.go builds the same commands from a served spec, which is what lets the CLI drive a service it was not linked against. cmd/zipdoc + internal/zipdoc close the gap Go leaves: doc comments do not exist at run time, so prose reaches the spec only via a build-time AST pass. Field docs and examples are extracted from source and keyed "<Type>.<field>". Two defects found by making the projections agree: commandName treated "ends in a parameter" as the only way to address one thing, so GET /v1/paas/apps/:app/deploy became apps-deploy-LIST — named for a collection, returning one deploy. The route already says which it is past its parameter: /apps/:app/deploy is singular, /nodes/:node/jobs is plural. OpenAPI parameters carried no description while the registry had one, so the same argument had help in-process and none from the spec. Both now read the same key, and query parameters get it too. internal/jsontag exists so the reflect view of the wire-name rule and the AST view cannot drift; transport gains a dial-only https, because reaching a published API is an ordinary Mount and terminating TLS stays the ingress's job. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
4b79de5cc4 |
feat(typed): a typed op reads the whole URL, not half of it
An op bound its input from the body and the path and stopped there, so the query string — the other half of the URL — was unreachable. That made every route carrying `?q=` inexpressible as a typed op, which is most of a read API: it had to stay an untyped handler, and an untyped handler is absent from BOTH projections. Measured on hanzoai/cloud: 1372 live routes, 0 MCP tools, because not one route could be typed. bindPath becomes bindURL — one binder for both URL sources, because a path param and a query param are the same kind of value (a name and a string carried by the URL) and two functions for one idea drift. It converts by the field's kind (string, bool, sized ints/uints, floats) instead of binding strings only. The three sources bind in increasing authority: body, then query, then path. Query beats the body because it is part of the URL; path beats query because it is the part the router MATCHED on. The authorizer still runs below all three on the same decoded value, so the target it authorizes is the target the URL named. A malformed scalar leaves its field zero rather than failing the call: `?limit=abc` is a caller's typo about ONE field, and refusing the request would make a typed GET brittler than the untyped handler it replaces. `validate:"required"` is how a field becomes mandatory. The document follows the binder: a bodyless op declares its non-path input fields as query parameters, typed and optional. hasBody() is the single predicate deciding "body or query" for both the binder and the doc, so the two cannot disagree about what a request looks like. MCP is untouched — a tools/call carries every argument in its JSON object, so neither query nor path binds there. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
0ff1091763 |
feat: the doc comment IS the spec — descriptions, field docs and examples
A spec with no descriptions and no examples is a reference someone reads, not
one they can press "try it" on, and that gap is most of what an API portal is
for. This closes it without anyone writing anything twice.
Go does not retain doc comments at run time — reflection sees types and struct
tags, never comments — so prose can only reach the spec through a build-time
pass over the AST. That constraint is the whole design. The alternative is
WithSummary("…") sitting directly under a doc comment that says the same thing:
two places to change, one to forget.
cmd/zipdoc (next) walks a package and emits Describe calls carrying the handler's
doc comment, each In/Out field's comment, and the Example/Response bodies written
in that comment. buildOpenAPI merges them: operation description, per-field
descriptions keyed by Type.field so an In and an Out "limit" do not collide, and
request/response examples emitted as raw JSON so a malformed one is wrong loudly.
The summary falls back to the comment's first sentence, because Go's own
convention already makes that a summary — so nobody writes a summary at all.
Degrades cleanly: with no generated file the spec is exactly what it was, schemas
without prose. Nothing breaks by not running the generator; it just stays terse.
Co-authored-by: Hanzo Dev <[email protected]>
|
||
|
|
93ca85dda2 |
feat(openapi): export OpenAPISpec so a service can render its own contract
The spec was reachable only by serving it. A published contract is produced by a build step, not by booting a server and curling it, so the document the routes imply is now directly available — one source of truth for the routes, the docs, and every generated SDK. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
b9e5c40257 |
feat(typed): bind URL path params into the typed input
A typed op decoded only the request body, so a route could not carry its target in the URL — which is why every service that needed to address one resource spelled it as POST /<resource>/get with the key in the body. The dialect was a symptom of the framework, not a choice. Path params now bind onto the decoded input by json tag, applied AFTER the body so the URL wins. The URL is what routed the request, so it is the addressing authority, and because the authorizer runs on this same decoded value the target authorized is by construction the target the URL named — a body cannot smuggle a different one past it. Only top-level string fields bind: a path param is a string on the wire, and a nested record is not a path target (an input that nests declares its target explicitly instead of having it guessed out of a sub-struct the caller also controls). Routes with no params are untouched, so no existing op changes. OpenAPI now declares every templated segment, derived from the same route pattern the router matches on, so the spec cannot describe a parameter the route lacks. MCP passes no path params: a tools/call carries every argument in its JSON arguments object, so the body is the whole input. Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
5e4ca54293 |
zip: ride zap-proto/fiber v3.2.1 — specificity route precedence, natively (#2)
Swap the engine gofiber/fiber/v3 -> zap-proto/fiber/v3 (our fork: gofiber v3.2.0 + ServeMux-1.22 route precedence fixed IN the router). Most-specific pattern wins regardless of registration order, ambiguous equal-specificity overlaps panic at registration, use/mount stay declaration-ordered barriers. No zip-side buffering/finalize — the framework surface is unchanged; six specificity tests pin the contract at the zip surface (engine-agnostic). Kills the consumer order-int idiom (subsystem order ints existed only so specific routes registered before wildcards). Co-authored-by: Hanzo Dev <[email protected]> |
||
|
|
c581e0af7b |
feat: zip canonical home — the ZAP-native web framework, consolidated
zip is shared protocol infra (the fiber-over-ZAP web framework used by every
Lux/Hanzo/Zoo Go binary — nodes, gateway, cloud, iam, kms, o11y, ai, …), so its
home is the ZAP protocol family (zap-proto/{go,http,zapd,zip}), NOT any one org's
brand namespace. This consolidates the rich framework (formerly hanzoai/zip v0.5.0
— Ctx/Handler/Errorf/Err*/HTTPError, typed handlers, middleware, runtime/extension
loading, openapi, validate, zaprpc) with the real ZAP transport (ListenZAP/
ListenHTTP/Serve over zap-proto/http) into ONE canonical module.
Module path github.com/hanzoai/zip -> github.com/zap-proto/zip. Code identical to
hanzoai/zip v0.5.0; promoted to v1.0.0 as the stable shared-infra framework. The
prior hanzoai/zip lineage is retired — one home, one import path, one way.
Verified: go build/vet/test ./... green; the ZAP round-trip integration test serves
/v1/health over the real ZAP transport (200 + handler JSON).
|