39 Commits
Author SHA1 Message Date
zeekay 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.
2026-10-09 09:42:01 -07:00
zeekay 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.
2026-10-09 09:42:00 -07:00
zeekayandHanzo Dev 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]>
2026-09-23 01:53:45 -07:00
zeekay 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.
2026-09-15 11:41:56 -07:00
zeekayandHanzo Dev 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]>
2026-09-11 10:36:14 -07:00
zeekayandHanzo Dev 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]>
2026-09-06 11:14:59 -07:00
zeekayandHanzo Dev 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]>
2026-09-04 00:16:37 -07:00
zeekay 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.
2026-08-29 23:16:02 -07:00
zeekay 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.
2026-08-29 22:15:33 -07:00
zeekay 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.
2026-08-29 22:11:11 -07:00
zeekay 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.
2026-08-29 14:05:53 -07:00
zeekay 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.
2026-08-29 01:03:21 -07:00
zeekay 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.
2026-08-24 15:07:03 -07:00
zeekay 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.
2026-08-24 15:00:59 -07:00
zeekay 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.
2026-08-24 14:54:29 -07:00
zeekay 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.
2026-08-24 14:27:22 -07:00
zeekay 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.
2026-08-21 13:11:24 -07:00
zeekay 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.
2026-08-12 18:34:12 -07:00
zeekayandHanzo Dev 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 2c2991e: hanzoai/iam contributes 95
schemas, not 345. 345 was the changed-line count in plugin/iam/openapi.json,
not the schema count. Prose only, in two doc comments; the code was right.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-05 11:12:39 -07:00
zeekayandHanzo Dev 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]>
2026-08-04 20:32:35 -07:00
zeekayandHanzo Dev 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]>
2026-08-02 20:04:06 -07:00
zeekayandHanzo Dev 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]>
2026-08-02 19:43:13 -07:00
zeekayandHanzo Dev 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]>
2026-08-02 12:55:22 -07:00
zeekayandHanzo Dev 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]>
2026-08-02 12:34:35 -07:00
zeekayandHanzo Dev 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]>
2026-08-01 10:48:10 -07:00
zeekayandHanzo Dev 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]>
2026-07-30 07:01:38 -07:00
zeekayandHanzo Dev 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]>
2026-07-30 06:28:46 -07:00
zeekayandHanzo Dev 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]>
2026-07-29 17:54:28 -07:00
zeekayandHanzo Dev 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]>
2026-07-28 18:29:20 -07:00
zeekayandHanzo Dev 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]>
2026-07-28 17:07:47 -07:00
zeekayandHanzo Dev 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]>
2026-07-28 16:37:57 -07:00
zeekayandHanzo Dev 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]>
2026-07-28 12:56:05 -07:00
zeekayandHanzo Dev 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]>
2026-07-27 17:02:36 -07:00
zeekayandHanzo Dev 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]>
2026-07-27 16:36:11 -07:00
zeekayandHanzo Dev 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]>
2026-07-27 16:27:27 -07:00
zeekayandHanzo Dev 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]>
2026-07-27 11:39:26 -07:00
zeekayandHanzo Dev 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]>
2026-07-27 11:15:39 -07:00
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]>
2026-07-10 13:38:47 -07:00
zeekay 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).
2026-07-01 23:08:35 -07:00