Red's re-review of v1.37.43: the budget counted resolver calls and
objects, but a plain list a resolver returned costs no further call,
so five objects whose field answered 2,500 rows each came back as
12,513 objects and 1.16 MB from about fourteen counted resolutions.
Each list member answered, plain or object, now costs one, in value's
list loop and project's; past fieldLimit the request is refused whole
as before.
Red's review of v1.37.42 found that a small request could make a graph
resolve without end: thirteen fully connected objects with `next`
nested four deep is 28,561 objects from a few dozen bytes, and the
static bounds cannot see a fan-out that lives in the data.
- fieldLimit is now also a runtime budget: every resolver call and
every object reached counts, across the whole request. Past it the
context every resolver holds is cancelled, no further call is made,
and the request is refused whole.
- A query's root fields resolve rootParallel (8) at a time, not all 64
at once.
- A caller reads the message of an HTTPError with a 4xx status and
nothing else: any other error, and a panic, is logged with the
request id and answered as "internal error (request <id>)". The
POST handler takes or mints X-Request-Id and sets it on the request
and the answer.
- Selection.Bind refuses in the schema's words ("argument "limit" of
"assertions" takes Int, not "x""), as 400s; a local op's arguments
get the same wording.
- Group.MountGraphFor mounts a graph behind a group's middleware.
- Identity(ctx) is what a Call would carry as identity, for a relay
that strips the identity it copies.
- A list of structs is projected member by member, which every
`{ items { name } }` on a local op used to refuse, and a resolver's
struct answer is checked against its type.
CheckSchema reads the type-system half of GraphQL and says whether a
client can build against a schema: every type it names defined,
nothing defined twice, a field answering an output type and an
argument taking an input type, unions of objects, roots present. It
runs on no request; a graph's tests run it, so a host composing a
schema no longer needs a second GraphQL library to find out whether
the schema parses.
It found one in zip's own: GraphQLSDL named JSON for every map,
interface and self-marshalling value without ever declaring it. It
declares the scalar now when a field uses it.
A graph served for something else (MountGraphFor) now gets what a
second engine had been written for:
- The request is gathered before anything resolves: fragments spread
once per selection set, cycles and unknown fragments refused at
parse, @skip and @include decided (on inline fragments too), fields
under one key merged, variables put in place. A Resolver receives
the field as a Selection with its whole subtree, so it can fetch
only what is selected or refuse a request too deep to answer.
- An Object answer is asked for each selected field with that field's
own arguments, so nesting can be traversal. Selection.Bind decodes
arguments into a struct, refusing unknown ones.
- The schema is rendered per request with the caller's context, and a
schema that fails is a 500 that says so.
- Bounds on the gathered tree refuse whole: 64 root fields, 10000
fields in all, depth 64.
- A query's root fields resolve at once; a mutation's in order. A
panic fails its field.
- Keys go out in the order selected. A given variable the operation
does not declare is ignored, and a declared one given nothing leaves
its argument out, both as the specification says.
- MountGraphFor's routes are declared, so a host routes to them and a
document describes them; Headers(ctx) relays a request whole.
contract/ and examples/local-service/ named zip's go line and its
dependencies as they stood before websocket upgrades and go 1.27, so
`go test` in either refused with "updates to go.mod needed". The
collector test also registered two untyped handlers through Get, which
takes a typed one since generic methods; they are Raw now.
openVerified copied every plugin binary into the instance's private
directory before exec, digest or not. The copy is what makes "what was
checked" and "what runs" one object, and it stays for a plugin with a
Sum. Without one nothing is checked, so the copy kept nothing equal:
the bytes at the path when it was taken are the bytes that ran. It cost
a full write of the binary on every lazy start (Hanzo's per-app
plugins are 24-145 MB), on whatever disk Dir sits on, and gave every
instance its own page cache.
A plugin with no Sum is now opened read-only and executed through that
descriptor. An embedded Bin is written once, under ForkLock (place),
and executed from there; before, it was written and then copied.
Benchmark_OpenBinary (the file handling alone, Apple M4 Max, APFS,
go1.27.1, -count=6, median):
size before after
16 MB 3.94 ms 0.026 ms
48 MB 12.11 ms 0.026 ms
144 MB 36.66 ms 0.026 ms
Benchmark_ColdStart (Load lazy, first request through the host, test
plugin padded to size, same host). On macOS the delta is far larger
than the copy itself: exec of a just-written file costs it more than
the bytes do. Expect the Linux gain to be the copy above, not this:
binary before after
15.8 MB 156.8 ms 7.8 ms
47.8 MB 294.8 ms 7.1 ms
143.8 MB 767.2 ms 7.3 ms
Co-authored-by: Hanzo Dev <[email protected]>
A url-encoded form is refused when it carries 10000 or more '&', so
more than 10000 pieces whether fields or empty, by one bytes.Count
before anything is split. Counting only non-empty pieces split the
body one bytes.Cut per separator, so 4 KiB of gzip that decoded to
4 MiB of '&' cost about 60 ms of CPU before fasthttp parsed it again.
A url-encoded form is refused past 10000 fields, the bound Go's own
query parser holds, before any field is decoded, and a form keeps only
the keys In binds. A few kilobytes of gzip that decode to millions of
empty fields cost the decode and are answered 400, where they allocated
hundreds of megabytes a field at a time.
Every Content-Encoding line is undone, since two lines say what one
comma-separated line says, and a chain of more than three codings is
415 instead of being decoded layer by layer up to the body limit.
fasthttp reads a multipart body as a form before routing, so over HTTP
a Body field holds that form written back out, which is what c.Body()
gives a raw handler; ZAP carries the bytes as sent. Body's doc says so
instead of promising the bytes as sent on every transport.
An op that takes the body itself (a Body field, a form, a Parser) read
it through fiber's Body, which answers a Content-Encoding it cannot
undo with the error's text in place of the bytes, so the handler ran on
"gzip: invalid header". It now reads the content: each coding undone
and held to BodyLimit, 415 for a coding the server does not undo, 413
past the limit and 400 for one that does not decode, all before the
handler runs. A form is read from that content, so a gzip form binds
its fields and a multipart form is parsed in memory from a body the
limit already bounds; a small request can no longer decode into a part
of any size. A JSON op reads fiber's Body as before.
An answer refused after its handler ran (an undeclared status or
header) closes its Reader, so a relay no longer leaks the upstream
body.
The graph carries only plain ops, one JSON value in and one out: bytes,
forms, Parsers, streams, sockets, redirects, relays and unions are no
field, so none of their handlers runs there without the checks its own
door makes. A cookie: field is never a graph argument, and Here refuses
a socket op before its handler, as the call plane and MCP do.
A typed op's form field, cookie, multipart part and raw body are each
read off the connection's reused RequestCtx, so each is held to the
rule the other accessors already meet: a value kept past the handler
still reads as it did after the next request on the same connection,
over HTTP and over ZAP. The cases fail when readForm hands out a view
of PostArgs instead of a copy.
A socket op's handshake is decided before its handler runs, so a
handler with effects runs only for a connection it will get: 426 for
another protocol version, 400 without a key, and 403 for a browser on
an origin the op does not admit — its own, plus zip.Origins(...). The
origin policy is the op's, not the answer's, so Socket carries only
Serve, Subprotocols and Compression, and wsx's own refusals are back
to the library's. The in-process CLI refuses a socket op before
invoking it.
An op that answers JSON or bytes keeps JSON's media for its JSON when
Produces names the bytes'. 204, 304 and a redirecting op's 3xx carry
no body in the document; any other declared status, 3xx included,
carries the Out and the doc comment's example, as before. An MCP read
of a Reader is bounded in time as a stream is, and the reader closed.
TestConnMemory counts the goroutines holding connections added, as it
counts the heap they added, so goroutines other tests leave winding
down are not charged to the server.
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.
A type that embeds Redirect or Body is that answer, so a sign-in leg
answers 302 with two Set-Cookie lines and no body, and a confirmation
page answers its HTML with a cookie, through CookieCoder on the
embedding type. Verbatim reads its Body the same way instead of by
reflection.
An answer with no bytes and no Type carries no Content-Type, so a 304
is a Body with Status 304 and nothing else, as a handler that wrote
nothing never sent one. A filename has its quote and backslash escaped
and its control characters dropped, so it cannot end the header.
Every field of Command, Arg and Flag carries its own comment, so the
command projection's shape is described where a document publishes it.
The registry's body flags keep field order.
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.
An op is an async fn. It may take &zip::Cx (request headers; the
answer's status, headers and trailers) and a zip::Body read as it
arrives, and may answer zip::Sse or zip::Body, each chunk written as
the op yields it. A client that leaves drops the op's stream.
The blocking std::net door is gone: HTTP/1.1 keep-alive and h2c on
one port (hyper_util auto), graceful shutdown bounded by a grace.
The ZAP door runs on tokio and streams with zap-proto/http's head,
data and end frames.
Wire names are serde's: the derive reads rename, rename_all, skip
and with, and the hand-rolled JSON is deleted. The info manifest
comes out byte for byte as before.
No zapgen emits Rust, so the crate never built; the frame codec is
written in zap.rs and pinned to frames Go wrote. The length prefix
is big-endian, as zap-proto/http's transport writes it.
examples/stream is a JSON op, an SSE op with a trailer and a byte
relay, tested over raw HTTP/1.1, h2c and ZAP sockets.
Claude-Session: https://claude.ai/code/session_01SCPMGSQsLNF2YtHFL4bwyp
"stream": "sse" or "bytes" publishes text/event-stream or
application/octet-stream instead of the 204 an op with no out
projected as; "raw": true publishes an octet-stream request body.
Check refuses a stream beside an out and a raw body beside an in.
Claude-Session: https://claude.ai/code/session_01SCPMGSQsLNF2YtHFL4bwyp
Both transports reuse a RequestCtx for the next request, and a handler's
context read the request's headers lazily, so a context kept past its handler
answered with whichever caller the RequestCtx was serving by then. The context
now holds the request through an inflight the request owns: the server closes
it when it resets the request, and every read holds it while reading. A context
kept past its request answers with no caller, forwards no identity, attests no
peer and is not a local call.
Co-authored-by: Hanzo Dev <[email protected]>
mustBuild read the draft's error off the App while liveOrBuild wrote it under
buildMu, so a goroutine reaching the router raced a concurrent build;
liveOrBuild now returns the error it read under the lock. Detach is the context
for work that outlives a handler: the server reuses a request once its handler
returns, so a goroutine holding the handler's context read the next request's
headers. Detach states the caller while the request is still its own, on a
context that no longer reaches the request and is not cancelled with it.
Co-authored-by: Hanzo Dev <[email protected]>
Every child writes to the same host stream, and the tag and the line went out
as two writes under only that child's lock, so another child's line could land
between them: it carried the wrong plugin's tag and the next line carried none.
The line is assembled and written once, under a lock all children share; a last
line without a newline is flushed when the child exits.
Co-authored-by: Hanzo Dev <[email protected]>
A child forked by one start holds a copy of every descriptor the host has
open until it execs, including the write handle another start is copying
its binary through. Linux refuses to exec a file anyone holds open for
writing, so plugins started together failed each other with "text file
busy": 2 of 16 in TestStart_TogetherAllRun without this, none with it.
Holding the read side of syscall.ForkLock while the copy is written keeps
every fork out of that window.
Co-authored-by: Hanzo Dev <[email protected]>
An unexported handler documented with its exported spelling -- `list`
carrying "List returns the caller org's live bot runs" -- named itself as
exactly as "list returns" would, and kept the identifier: "List" is one
hump, so the shape rule cannot read it as a symbol. 136 lifted comments
across the cloud fleet open this way, and each reached the document, the
CLI help, the MCP tool and the SDK docstring as "List returns ...".
The case-folded name takes the shape rule's verb guard, so "Health is a
liveness probe" keeps its subject.
Co-authored-by: Hanzo Dev <[email protected]>
traceOf read the caller's ids as slices of a view over the header
buffer, then rewrote that buffer in place: every shipped span named
itself as its parent, and once the connection served its next request
the span carried that request's trace id.
Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
A host that keeps spans in its own tracer opens positions zip never
sees: a span inside a handler, or work that outlives its request. An
outbound call then names the request, or nothing, as its parent, and
the callee's spans land beside the span that caused them.
Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
Administering one's own org says nothing about the target, so the derived
caller reads and forwards OrgAdmin as false. Owner and Admin, the principal's
own platform authority, are unchanged.
Co-authored-by: Hanzo Dev <[email protected]>
zap reads text zero-copy over the frame that arrived, and Call releases that
frame to the pool when it returns, so a reply's strings pointed into memory the
next call overwrites. The generated codecs already copy text for this reason;
the reflective decoder now does the same.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
forwardIdentity wrote the inbound request's headers, or the stated caller,
and never read the caller ActingAs derived, so the next hop saw the original
org, the original project and no X-Acted-By. The derived caller now wins, as it
does in CallerOf, and is written whole: the org acted for, the actor, the
principal and its billing-account claim, and no project. Trace context still
comes from the request.
Co-authored-by: Hanzo Dev <[email protected]>
X-Billing-Account-Id joins the identity set Call forwards, and Caller.Account
carries it: headers() writes it and CallerOf reads it, the same as every other
identity field. A callee that resolves who pays now reads the account the
gateway minted, over a call as over REST. A credential with no claim sends no
header.
Co-authored-by: Hanzo Dev <[email protected]>
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]>
Call, remote invokes, proxy hops and CLI commands all called Client.Do,
which takes no ctx, so a cancelled or expired caller kept waiting until
the transport's own read timeout. zap-proto/http v0.3.11 adds
Transport.DoContext; a client that implements the new ContextClient
interface is now driven through it, and Call returns an error wrapping
context.Canceled or context.DeadlineExceeded.
Co-authored-by: Hanzo Dev <[email protected]>
routerPrefix promises that an unresolvable router "is an error naming the
call, never an assumed empty prefix", and then returned the empty prefix for
a group. So a group handed to another function filed its ops at the path
after the prefix — an address nothing serves — in the document and the MCP
tool alike, which is the silent drop the resolution exists to end.
The one shape this hits is a group reached through a parameter: the prefix
belongs to the call that made the group, and the call is in another
function. The error says so now, rather than leaving the reader to infer it
from a path that looks plausible.
Co-authored-by: Hanzo Dev <[email protected]>
cmd/zipdoc files every entry under the package that declared it, raw routes
included. A raw route records no registrar — a method, a path and a handler
chain is all App.raw keeps — so an address is all anyone can ask by, and the
address lookup could not reach a qualified key. A service's whole untyped
half therefore documented as silent the moment its generated file was
rewritten: 57 of iam's routes at once.
So the keys are indexed by address as they are recorded, and only the
unqualified lookup reads that index. A caller that names a package asked
about that package, and answering it with another's sentence would document
one service with another's words.
An address two packages describe differently is dropped from the index
rather than resolved. One address answers once in one process, so two
claims on it are a conflict, and choosing either would be a guess; the
qualified lookup still reaches both.
Co-authored-by: Hanzo Dev <[email protected]>
A host that re-exports the type writes `type Router = *zip.App` and hands a
Router to every plugin. That is the same type, but it arrives as
types.Alias, so matching the shape directly saw neither an App nor a Group
and the op's prefix could not be resolved — which refuses the whole
document rather than filing one path wrongly. Unalias at each place a type
is matched by shape, including the In and Out inferred from a handler.
A group is also read when var names it, not only :=. The prefix belongs to
the Group call; the syntax that binds the result does not change it.
Co-authored-by: Hanzo Dev <[email protected]>
Cloud composes each plugin as its own definition and asks Declaration for
its routes before composing it. A projection of a program that does not
compose panics rather than lying, so a plugin whose middleware guards no
routes took the host down with it; Build answers instead, but installs a
generation and freezes the definition the host is about to compose by
reference.
Verify is Build's first half and the same call — walk, structural rules,
derived rules, rule adoption — and stops there. The host reports which
group is wrong and carries on.
It answers for the definition as the root of its own walk. Middleware at a
served root is exempt, because composition brings it routes later; one
level down the same middleware guards a settled subtree. Only the host
knows which a definition turned out to be, so both verdicts are right and
the tests say so.
Co-authored-by: Hanzo Dev <[email protected]>
v1.37.9 fixed the REST path and the three by-name seams that already looked the
op up in the composed registry. Two did not: the graph resolved its ops from
that same registry and then invoked them without saying so, and the in-process
invoke did the same. So the contract fell back to the declaration — for anything
declared on a group, the bare leaf — and a rule reading op.Path governed the
REST call while missing the graph call to the SAME operation.
A test now asks both seams about one op and requires the same answer.
Co-authored-by: Hanzo Dev <[email protected]>
The authorizer and the result hook were given the op as DECLARED: for anything
declared on a group, the bare leaf and an id derived from it. So a rule keyed on
op.Path governed an address nobody serves — /probe/:id where the route table,
the registry and the document all say /v1/crm/probe/:id — and one definition
mounted at two prefixes reported a single name for two operations.
They now see what the route matched: the composed pattern, and the composed id
by the same rule the registry uses, where a declared id is the operation's own
name and composition does not get a vote. The concrete address stays off Op,
because path parameters are input and the hook is promised the operation and the
outcome and never the input; a handler that needs the resolved address asks
AddressOf.
Co-authored-by: Hanzo Dev <[email protected]>