59 Commits
Author SHA1 Message Date
zeekay 34f6959aff Count every list member a graph answers against the request's budget
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.
2026-10-19 08:38:48 -07:00
zeekay 017aeda6bf Bound what a graph request may resolve, and tell a caller only what was meant for it
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.
2026-10-19 02:22:44 -07:00
zeekay 1846d16194 Check a schema a graph publishes, and declare JSON where zip's uses it
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.
2026-10-18 20:17:03 -07:00
zeekay 5331b2e456 One GraphQL engine: a resolver is handed the whole selection
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.
2026-10-18 20:08:59 -07:00
zeekay cba7649b6b Count a form's separators in one pass
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.
2026-10-09 12:35:59 -07:00
zeekay 1dc336e5e2 Bound form fields and body codings; say what HTTP does to multipart
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.
2026-10-09 12:17:22 -07:00
zeekay ebb52ca808 Take a body's content or refuse it; keep the graph to plain ops
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.
2026-10-09 10:29:22 -07:00
zeekay 8cd2f7ac34 Decide a socket handshake before its handler; bound every tool read
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.
2026-10-09 09:42:02 -07:00
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
zeekay e2f4d75160 fix(ctx): every string a request hands a handler is a copy, so the next request on the connection cannot rewrite one it kept 2026-10-09 05:53:27 -07:00
zeekay c7694a7285 zip in Rust serves on hyper, and an op can stream
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
2026-10-08 12:56:07 -07:00
zeekay bb5a49b3b1 fix(shutdown): bound the HTTP connection drain by the shutdown context 2026-10-04 16:43:53 -07:00
zeekay a255389195 fix(shutdown): wait out the children past an ended context 2026-10-04 16:29:53 -07:00
zeekay 0967e316ce feat(load): stop a child with SIGTERM and a 20s grace, all children at once 2026-10-04 16:09:53 -07:00
zeekay a4bcdce4d2 fix(host): release every hold, keep a cold burst under the ceiling, decode lists in one pass; retract v1.37.31 2026-10-03 20:29:11 -07:00
zeekay f9ace4429f fix(zapenc): a pointer to a zero value crosses as present, nil as absent 2026-10-03 19:07:48 -07:00
zeekay 710cfc6694 fix(adapt): a net/http handler's request owns its strings, so a header it keeps is not overwritten by the next request
Claude-Session: https://claude.ai/code/session_01AGuBEqCjuman4E9FdKEgeV
2026-10-03 02:22:20 -07:00
zeekay 0075bc119d feat(adapt): a net/http handler takes an upgrade's connection, so a WebSocket server behind AdaptNetHTTP answers 101, not 501
Claude-Session: https://claude.ai/code/session_01AGuBEqCjuman4E9FdKEgeV
2026-10-03 00:03:26 -07:00
zeekayandHanzo Dev c53d10fe4b A README to build a service from, and examples/local-service
The README gave no version to install and no service to run, never
mentioned zipdoc, Described, ErrorHandler, the call plane's field-order
rule, zipgen, CommandsFromSpec, Authorize or the MCP door's limits, and
named APIs that do not exist (app.Add, app.Mount, a zip.Load that takes a
prefix first). It now takes one service through REST, MCP, OpenAPI and the
CLI with output from real runs, then covers each projection, the error
format and the release that introduced it, and the security model.

examples/local-service is that service: one SQLite-backed operation, doc
comments run through zipdoc, a .mcp.json for Claude Code, and a test that
checks every operation against every projection. It is a module of its own
so the SQLite driver stays out of zip's graph. CI runs zipdoc -check and
its tests.

LLM.md is now a map of the code, the test commands, the release steps and
the invariants that hold today.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-11 10:36:14 -07:00
zeekayandHanzo Dev cda55906cf Call the generator Layouts, not Codecs
A codec is a runtime encode/decode step, which is the thing ZAP removes: a
field IS its offset, and a read is a pointer into the bytes that arrived.
Nothing here runs on the served path — Layouts reflects at BUILD time and emits
constant offsets. Naming it for the layer it exists to delete told readers the
opposite of what it does.

LayoutOf already names one type's shape; this emits those as source.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:34:08 -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
zeekay b91ba940b5 An id crosses in the Go SDK, on the codec the SDK writes for it
The reflective encoder refuses bytes_fixed[N] — an ids.ID is [32]byte — so that
an id forces its type to state its own wire. The SDK read that refusal as a fact
about the op and reported a gap. It is a fact about the ENCODER: the layout is
right, and Codecs already writes the two methods that carry an id inline from
those same offsets.

So the SDK writes one. Not a second emitter — the same one, asked for names
through a seam, because two emitters would be two wires and the second would be
spoken by nobody. The restatement is the declared struct field for field and in
order, so its layout IS the declared layout; the codec states the declared
offsets against this package's names.

Which types get one is not all of them. Wire is checked at the root of Marshal
and Unmarshal only, so a parent encoded reflectively reflects over its children
too. That makes the set closed upward, and a codec calling MarshalZAP on what it
nests makes it closed downward. One id under an op's In or Out states the wire
for that tree; a tree with no id keeps the derived wire it had, and its
generated source does not move.

Two guards, because a wrong offset is worse than a gap: the restatement is
checked against the layout it claims — same fields, same order — and a mismatch
is a gap, never a guessed layout; and spell preserves reflect.Kind, which is
what makes the widths identical.

Over node's nine apps: 43 of 94 ops reachable in Go, now 91. The three left are
an interface, which names no type. Every op is still a method XOR a gap. The
document, the tool list, the command tree and both HTTP clients are byte-
identical across the change.
2026-09-01 12:58:52 -07:00
zeekay a8d7e86e0c The emitted file declares no name of its own
A generator cannot know which identifiers a package has free, and
github.com/luxfi/utxo imports a package called wire. So the assertion is
an inline interface literal, and the two helpers a codec needed are now
block-local: the empty object builds where it is written, and a narrow
list element is zero-extended in the loop that reads it.

Same bytes, held against the reflective encoder as before.
2026-08-29 18:55:54 -07:00
zeekay 3f982c4a0b A value with no slots crosses as bytes, not as a method
time.Time and netip.AddrPort have no exported field, so the layout gives
them no slot and the reflective encoder writes a complete empty object for
them. A generated parent cannot reach for a method there — the type is in
a module nobody here can add one to — so it writes the same bytes inline.

Held byte for byte against the reflective encoder, inline and as a list
element, so the wire is the same one either side of the change.
2026-08-29 18:47:08 -07:00
zeekay 17589a6d6d A type can state its own ZAP wire, so an id can cross
zip.Codecs renders MarshalZAP/UnmarshalZAP from LayoutOf — the third
reader the derivation's doc already named, after the plane that encodes
against it and the .zap schema that states it.

bytes_fixed[N] is refused by the reflective encoder on purpose: an id is
the case that should force a type to declare its own wire. Until it does,
a reply holding an ids.ID cannot cross the plane at all. Wire is consulted
at the root of Marshal and Unmarshal only, so the emitted set is closed
downward and a parent reaches each nested value by its method.

The emitted file restates Wire rather than importing zip, so a leaf module
states its wire on the ZAP builder alone. The bytes are held equal to the
reflective encoder's, field for field, which is what lets a codec roll out
one pod at a time.
2026-08-29 18:40:44 -07:00
zeekay ca57a831ff The doc said five projections and no generated client
There are eight, and one of them is a generated Go client — so the
paragraph arguing that a generated client is always the wrong answer was
sitting one directory from the generator.

It was right about the case it was written for and wrong as a general
rule. Inside the fleet a caller imports the op's In and Out and Call is
the answer. A published SDK's users cannot: they do not link the service,
and linking it to reach three types drags in its whole dependency graph.
Both are now stated, with the line between them.

Also records why that generator reads the registry rather than the
document: field order, json:"-" and embedded structs are three shapes a
Go type has on the JSON edge and does not have on this wire, and each one
moves a field without failing.
2026-08-29 17:52:58 -07:00
zeekay 3dd01043ec Answer refusals as RFC 9457 problem documents; OAuth keeps its own vocabulary
A refusal now leaves by one door and is written in the vocabulary of the
address it happened at.

HTTPError loses its struct tags and becomes a plain value; MarshalJSON is the
single place that says what it looks like. Its three members already mapped
onto both standards exactly, so no field was added: Status is `status`, Msg is
`detail`, Code is an extension member (RFC 9457), and the same value reads as
`error` / `error_description` under RFC 6749 §5.2. With() members still merge
at the top level per §3.2, with the document's own written last.

zip.OAuth(r) returns a Router whose routes answer the OAuth vocabulary. The
fact rides the route entry rather than a table of addresses, so it survives
composition, and it is a property of the address rather than of the returned
value — which is what makes it reach the refusals no handler wrote (Bind,
validation, panics). A verb the endpoint does not serve is the router
refusing, and answers a problem document with Allow.

middleware.Recover wrote its own 500 body with c.JSON, a second error surface
that spoke only the default vocabulary; it now returns the error so the one
handler renders it. group() copies oauth beside wrap, so a nested scope keeps
the vocabulary.

Allow on 405 comes from the fiber fork's router and was already correct;
TestAWrongVerbSaysWhichVerbsWork now pins it in both directions so an error
handler edit cannot drop it.

Consumers reading `body.error` read `body.detail` now. Each repo takes the
change when it moves its own pin.
2026-08-21 12:47:57 -07:00
zeekay 7afa7ee42b here: a co-resident caller runs the op, and MCP rides zap-proto/mcp
Two things that are the same thing: a call does not need a wire, only an
address, and a protocol layer belongs in the module that names it.

Ask names an app and reaches it over that app's canonical socket. When the app
is THIS process that socket is a loop -- the value the caller holds is
serialized, handed to the kernel, read back, parsed into a fresh copy, and
dispatched to a function pointer that was in memory the whole time. Nothing
about the call needed a wire; only the ADDRESSING did, and addressing is what a
by-name call already has. So a bound listener records which App answers for it,
Serving reads that back, and Here runs the op through the op's OWN invoke seam --
the same validate -> authorize -> run core a REST request, an MCP tools/call, a
CLI command and a socket call all land on. One fewer transport, not one more.

That is what a co-resident caller uses INSTEAD of building a request for its own
router to take apart again. The pattern it replaces cost hanzoai/cloud a
re-entrancy counter keyed on a goroutine id parsed out of runtime.Stack, because
dispatching a request into the whole app re-ran every edge middleware and a
middleware that read the app it was serving recursed without bound. A direct op
call cannot express that shape: it enters one op, not one router.

MCP now comes from github.com/zap-proto/mcp v1.0.5 rather than being hand-rolled
here, so it sits beside zap-proto/http as a protocol layer over ZAP instead of
inside the framework as a special case.

TestServePathAllocsAreChainDepthInvariant fails at 34 allocs/request against a
budget of 1. It fails identically at v1.26.0 -- verified in a clean worktree at
that tag -- so it is pre-existing and not this change. Named here rather than
left for the next person to rediscover.
2026-08-05 21:29:52 -07:00
zeekayandHanzo Dev f048747237 a peer that is this process is a function call, not a socket
zip.Ask names an app and reaches it over that app's canonical socket. When the
app IS this process — a fused binary, or a service asking one of its own ops —
that socket is a loop: the value the caller holds is encoded, handed to the
kernel, read back, and parsed into a copy for a function pointer that was in
memory the whole time. Only the ADDRESSING ever needed a wire, and a by-name
call already has that.

So a bound listener records which App answers for it. Serving(name) reads it
back and Here runs the op through the op's OWN invoke seam — the same
opByName the socket plane uses, the same validate and authorize a REST request
and an MCP tools/call get. registeredOp.direct and registeredOp.invoke now
share one contract closure rather than each carrying a copy, so there is no way
into a handler that skips a check another way makes; direct differs only in
that it is handed the In instead of bytes to decode it from.

Serving goes false before the listeners close, so the in-process door and the
socket cannot disagree about whether the app is up.

This is what a co-resident caller uses instead of building a request for its own
router to take apart again. That pattern cost cloud a re-entrancy counter keyed
on a goroutine id parsed out of runtime.Stack, because dispatching into the
whole app re-ran every edge middleware and a middleware that read the subsystem
it was serving recursed without bound. Entering one op cannot express that.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-05 19:14:00 -07:00
zeekayandHanzo Dev cf68b751f3 Shadow is removed: a braided program is unbraided, not accommodated
v1.24.2 shipped two fixes. The operation-id one stays and is untouched here: a
declared WithOperationID survives composition verbatim (measured on hanzoai/o11y
— 353 ops, 217 declare an id, all 217 were being renamed by a host prefix and
now are not). The address one, App.Shadow(), is removed.

Shadow existed to make an op declarable in one place and answerable in another:
a service installing its published table of ops onto the router its
implementation already serves on, a test registering a second handler at an
address deliberately. Both are the same shape — a declaration and the handler
that satisfies it, written apart, with a framework verb holding the two ends
together. That is a braid, and a framework that grows a verb per braid grows one
verb per caller. The fix belongs in the caller: a typed registration is ALREADY
both halves, the handler the router installs and the op the document, MCP tool
list, CLI and SDK all read, so the declaration and the answer can simply be the
same line. Use composes definitions, Group scopes them, and between them every
shape Shadow was written for is expressible with one claim per address.

So the address check goes back to what it was, exactly: one registration at an
address, both parties named when there are two, no scope and no option that can
talk it out of an accident. addrClaim, addressErrors and addressRules are gone
with it — 190 lines of walk.go that existed only to reason about the SHAPE of
multiple claims at one address, a question the language no longer asks.

Removed: App.Shadow, OpScope.Shadow, route.shadow, App.shadow, wrapRouter.shadow
and yields, App.method's shadow parameter. wrapChain and OpScope lose their
nil-wrap branches, which existed only for a Shadow-only scope with no middleware
to compose. No caller outside this repo referenced any of it at a released tag.

Tests: shadow_test.go deleted (250 lines, ten cases, all about the removed
verb). oneclaim_test.go replaces it with three that pin the rule from both
sides — the braid is refused with both parties named, one registration declares
AND answers, and Use+Group place a definition's ops wherever a host wants them
with one claim per address. Everything else is unchanged, including all of
declaredid_test.go and the unnamedApp/billingApp split in compose_test.go, which
are the id fix and not this one.

319 root-package tests pass on a clean testcache and under -race.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 09:21:25 -07:00
zeekayandHanzo Dev 0ce276b638 a declared id is not composition's to edit, and a yielded address has a spelling
Two defects, one theme: a declaration the programmer WROTE is not something the
shape around it gets to overrule.

occurrenceID qualified every operation id with its occurrence's prefix,
including one the author had written with WithOperationID. Including
github.com/hanzoai/o11y under /v1/o11y therefore renamed its published ops to
v1.o11y.<id> — every MCP tool an agent had cached, every operationId, every CLI
command, every generated SDK method — as a silent side effect of one wiring
line. LLM.md's own Graft table has said `operationIds | untouched` since v1.18;
the walk had stopped obeying it. Measured on o11y: 217 of its 353 ops carry a
declared id, and all 217 were renamed by a host prefix. They are not now.

The rule asks one question first. A DECLARED id survives verbatim, because it is
a published name and a service embedded at a prefix publishes the same
operations it publishes standalone. An UNDECLARED one is still qualified by its
occurrence's prefix, deterministically and from the composition's shape, because
there no promise exists and a real ambiguity does: one definition included twice
is two operations and a document cannot hold two under one id. Where the two
meet — declared AND twice — is now a refused program that names both occurrences
and both fixes, instead of a silent rename that resolved an ambiguity the author
never saw.

Two registrations at one address was a refused program, and that refusal is
right. It also refused two shapes the estate writes on purpose: a service
installing its own published TABLE of ops onto the router its implementation
already answers on (o11y: 353 ops, publish() after the service's own routes —
"order is the contract"), and a second handler at an address declared to pin
what the router does with two (cloud's openapi generator test). Nothing in the
program said which it was, so the check could not tell them from an accident.

App.Shadow() says it. Scoped like With, and for the same reason — a group is an
App, so app.Shadow().Group(p) carries the property onto the group and every leaf
beneath it inherits it, including leaves registered later. Nothing about the
wire, the registry or any projection changes: the route installs exactly as
before and fiber chains it behind whatever came first. Only the conflict check
changes, and into two NARROWER rules rather than none —

  - exactly one claim at an address may decline to yield, and it must be the
    FIRST, because the router answers with the first registration. A shadow
    ahead of what it yields to would stand in front of it, so "order is the
    contract" is now checked rather than stated. Two claims that both mean to
    answer is still the accidental collision, refused in the same words.
  - at most one claim may carry an op. A document keys an operation on method
    and path; there is one slot, and two ops asking for it would publish one and
    drop the other. Shadow yields an address to another HANDLER, never a
    document slot to another op.

A shadow that is the only claim simply answers, which is what lets ONE table be
mounted into a host that has an implementation and into one that does not.

Shadow is on the concrete *App and deliberately NOT on the Router interface:
Router is implemented from outside this package method by method (cloud's scope,
commerce's mintRouter), and widening it is what stalled v1.19 adoption once.

Tests: declaredid_test.go and shadow_test.go, twelve cases, each failing before
the change — the id one with the literal v1.o11y.CreateRole, the address ones
with the refusal origin/main emits. compose_test.go gains unnamedApp, the same
definition with no declared id, because a definition that names its ops cannot
be the one included twice: the diamond is a test about the UNDECLARED half of
the rule and now says so.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-04 02:22:55 -07:00
zeekayandHanzo Dev b2159fbf09 acting for another org, a failure with a body, and a verbatim passthrough
Three survivors, and one of them was a live cross-tenant bug.

ActingAs derives a caller acting on another org's behalf. It does NOT weaken the
laundering guard: it cannot invent a caller, only re-point one already
authenticated, and with nothing authenticated it refuses — so stating an
identity still cannot override an authenticated one. What it adds is that the
derived caller carries both facts. Org is whose data the call is for; ActedBy is
who chose to touch it; the original actor survives a chain of hops; and both
travel in the forwarded identity set, so the impersonation is visible at the
next hop instead of the trail ending at this process. That is the wrong-ledger
bug: the admin's own org was being forwarded.

It deliberately does not authorise. Whether a principal may act for an org is a
tenancy policy, and policy belongs in an Authorizer — this supplies the
vocabulary and the audit trail, nothing more.

WithStatus now accepts a declared non-2xx, for an op whose failure carries a
typed body the error envelope cannot express. The doctrine survives where it was
load-bearing: an error return still renders {status, code, error}, and that is
still the way to answer a failure with nothing extra to say. The test that
pinned the old rule is rewritten to state the new one rather than deleted.

Gap 9 needed no change: json.RawMessage as the input already round-trips a body
verbatim, unnamed fields and all. Measured, then pinned — a passthrough is
expressible as a typed op with an opaque declared body, so the RequestOf refusal
still stands.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 21:00:19 -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 78328b8f15 the caller's address, and it does not believe X-Forwarded-For
Caller.IP is the last thing forcing a Bridge install: cloud's request-needing
sites cover body, arbitrary headers, URL and IP, and only IP was still missing.

It is ambient, not declared — what the CONNECTION says, not something the caller
states about itself — so it sits beside identity rather than in the op's input
shape. Empty when no connection is behind the context, which is the same honest
answer every other Caller field gives and never a panic.

The trust question decides the rest. By default IP is the socket peer, which
cannot be spoofed and behind a load balancer IS the load balancer. zip does not
silently believe X-Forwarded-For: anyone can send one, and an IP that quietly
reports the proxy is worse than none because someone will rate-limit or audit on
it. A deployment behind a proxy opts in with Config.TrustProxy, and the opt-in is
ALLOWLIST-GATED — TrustedProxies names the peers whose forwarded header is
believed, and anywhere else it falls back to the peer. Setting TrustProxy with no
allowlist trusts nothing, which is the safe reading of an incomplete config.

The knobs are wired through to the router, because a trust setting that does not
reach c.IP() is a promise the code does not keep.

Measured: the typed path is 31 allocs/1388 B against 40/3063 on the previous
tag. The improvement is stable across runs but I have not confirmed its cause,
so it is reported rather than claimed — what is verified is no regression. One
allocation I did add, a per-request closure for the header reader, is now built
only for ops that declare a header.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 19:53:27 -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 41f9a5d0fc use: a handler that answers an address is refused, at every depth
Two things, both closing a gap between what walk.go states and what it checks.

A HANDLER MAY NEVER TERMINATE A REQUEST. Enforced now. The inert check only ever
asked something strictly weaker — that a definition carrying middleware has
routes beneath it — which app.Use(zip.Static(assets)) passes on any app with a
route, and its depth-0 skip meant the app being SERVED was never asked at all.
That is exactly where a wiring file writes it, and the static handler then ran
before every route in the composition and answered each one with a file, or a
404 where no file existed. walk.go's closing comment said so plainly: the rule
condemned no form anyone writes.

Terminality is now a property the walk can read. A leaf constructor returns its
handler through zip.Terminal — exported, so wsx.Upgrade and third-party leaves
come through the same door — and the identity is the closure's code pointer,
never a name match. AdaptNetHTTP and AdaptNetHTTPMiddleware return closures with
the same body from the same file, and only their authors can know which one
answers. The handler comes back unwrapped, so registering a leaf at an address
is byte for byte the registration it always was. The depth-0 exemption stays
where it belongs, in the inert check alone: root middleware is router middleware
and covers requests that match nothing, so it is never inert.

ONE INTERPRETER. routesUnder re-walked App.entries per definition with a cycle
guard of its own — a second authority that could disagree with the walk about
the program it was asked about, and that read a definition's LIVE entry list
rather than the one the generation was built from. It is now one pass over the
occurrence slice, because a trail cell carries its *App, which makes an
occurrence's breadcrumb its ancestor chain; a cyclic composition never reaches a
reducer, so there is no guard to need. materialise reads its Handler through the
third accessor the other two always documented, instead of asserting the
payload.

The two AST guards in binding_test.go become one that is strictly stronger:
function granularity rather than file, bare type assertions as well as switches,
and every allow-listed site must still be HIT — so deleting or renaming one
fails the test rather than leaving it quietly vacuous.

derived() already owns operation-id collisions; nothing here touches it.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 14:32:27 -07:00
zeekayandHanzo Dev 0692608056 declaration projects the program; Fiber() escapes are refused; Listen is Serve+Wait
Three things a consumer migration surfaced.

HEAD shadows. Declaration filtered ALL HEAD and OPTIONS, which also deleted an
EXPLICITLY declared HEAD — a real door a host would then answer 405 on, which is
the analytics-outage class this type exists to prevent. The doctrine was right
and the mechanism was wrong: a declaration now projects the PROGRAM, not the
materialised router, so shadows never appear (nothing wrote them) and declared
HEAD does. App.All is the one entry that is genuinely many doors, so it expands.
Consumers enumerating routes should read Declaration, not Fiber().GetRoutes —
fiber's own method cannot apply a doctrine zip owns.

Fiber() escapes. Fiber() hands out the CURRENT generation's router, and a
generation is a projection, so app.Fiber().Use(cors.New(...)) was silently
discarded by the next build — no error, no CORS headers. A doc line is weak
medicine for a lost CORS policy, so the generation records its route count and a
rebuild that finds more refuses, naming the count and pointing at app.Use.

Listen. Serve is the one serving mechanism and Listen is now literally Serve
followed by Wait — sugar, not a second path. The no-handle shape is deliberate
and documented: a fixed program wants the terminal form, anything that loads at
run time wants the handle.

And a bug of my own, caught by TestLoad_EmbeddedBinary: the first cut of Serve
left h.errc nil, so Wait returned immediately and Listen stopped serving at all.
The test I wrote for Listen passed against that, so it has been strengthened to
dial the socket and assert Listen has NOT returned — verified to fail without
the fix.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 14:20:25 -07:00
zeekayandHanzo Dev f4e20591c5 v1.20: the slice returns, the host owns the live verbs, two validation stages
Three deltas against z's final language and runtime documents.

1. walk(app) ([]occurrence, error). The materialised form is deliberate: the
   alternatives are rewalking once per reducer, buffering secretly, or
   rendering during traversal — which weakens validation-before-rendering, the
   ordering every other guarantee rests on. occurrence is unexported and
   non-semantic; the tree stays canonical. What died was its claim to be part
   of the language, not the slice. Each generation retains its walk result so
   live reducers reuse it.

2. Include/Drop/Reload move to a Host, obtained from zip.Serve. Use extends a
   program; Include publishes a new generation of a running one. This deletes
   the process-level server set, reachability filtering, creation-order locks
   and cross-host commitment — they existed only to answer which hosts an
   App-anchored call reached, and host anchoring makes that unaskable. A shared
   definition is immutable everywhere; a new version is Reloaded per host.

3. Validation splits into named stages: structural (cycles, conflicts, inert
   middleware) then derived (id derivation and post-derivation collisions).
   Derived cannot run earlier — two occurrences can be structurally fine and
   still derive one operation id, which is a new check and now caught.

Binding requirement enforced mechanically: the only type switch over node lives
in walk.go, and no validator or reducer reopens App.entries. The test found
three real violations, including Lint, which now consumes the walk.

Also: Registry(app) recomputes in the mutable phase rather than serving a
memoised draft, per the runtime doc — the draft cache had already produced one
stale answer. And the iam five-prefix trap is pinned: composing a definition
once per prefix is five occurrences, and prefixing an absolute-path definition
moves it, visibly, in the registry.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 13:56:18 -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 6a5ca4037d acceptance: the iam graft document is byte-for-byte unchanged
iamserver.NewApp(db) (hanzoai/iam v1.34.0) grafted into a 4-path host renders
an identical OpenAPI document under this branch and under released zip
v1.18.22 — same sha256, 85 paths, 102 operations. iam compiles against the
branch unchanged; it has no bare closure at a Use call.

The circulating 4->164 figure is not what this pairing produces: it is 4->85
paths / 102 operations.

Also gofmt: build.go was left unformatted by my own edits; caller.go,
examples/express-in-zip and internal/zipdoc/extract_test.go were already
unformatted on main.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 12:34:35 -07:00
zeekayandHanzo Dev db5203729e generations: a program is not mutable, it is versioned
Replaces seal-forever. A generation is a sealed program plus its projections;
the live system is one atomic.Pointer. A composition change builds N+1, walks
and validates it, and swaps only on success — so a colliding plugin fails the
build with breadcrumbs and the old generation keeps serving. Requests load the
pointer once on arrival and finish on their own generation.

Use pre-build (cannot fail); Include/Drop against a live system (transactions).
Drop is receiver-local: an entry belongs to the app that wrote it.

Also fixes a test of my own that trusted App.listening instead of dialling the
socket — listening is incremented before the bind.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 12:34:35 -07:00
zeekayandHanzo Dev f24360adf6 LLM.md: what the prototype is, and the six things the code proved
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 fb32c54715 runtime: the JS toolchain is opt-in — Loader/Module are declared at the root
The root zip package imported zip/runtime for exactly ONE thing: the type of
Config.Loader. zip/runtime imports dop251/goja (a JS interpreter) and
evanw/esbuild/pkg/api (a JS bundler) directly, so every consumer of zip
compiled a JavaScript toolchain to name an interface — and the interface was
itself only a type ALIAS of a dependency-free leaf, internal/runtime.

Loader and Module now live where they are consumed: the zip root, next to
App.Module, which is the only thing that calls them. internal/runtime and the
zip/runtime aliases are deleted — one home, one name. zip/runtime keeps goja
and esbuild and stays the JavaScript implementation; it is simply a separate
import now, so the cost lands on the binaries that ask for JS.

There is no hot-path coupling to invert: the root only ever calls through the
interface (a.loader.LoadOne) into a loader the consumer constructs and passes
in via Config.Loader. That contract is unchanged.

Measured with `go list -deps . | wc -l` in a CLEAN module importing only zip,
outside any other module graph:

    before  315 packages, 33 of them goja + esbuild
    after   258 packages,  0
    opt-in  314 packages, 33   (a consumer that DOES import zip/runtime)

57 packages leave and none arrive: esbuild 25, goja 8, x/text 9, regexp2 3,
sourcemap 2, pprof 1, go/ast+token+scanner, archive/zip, encoding/base32,
hash/maphash — plus the two zip packages no longer reachable.

hanzoai/cloud, which imports zip/runtime nowhere, measured against this commit
vs its parent (same zip baseline, so the delta is only this change):

    cmd/cloud        402 -> 346    (JS 33 -> 0)
    cloud root       603 -> 547
    plugin/crm       605 -> 549
    plugin/storage   716 -> 664

cmd/cloud builds, and `go build ./...` yields the byte-identical error set
before and after (3 pre-existing k8s/autoscaling failures, unrelated).

Also corrected a doc claim that was already false: Config.Loader said it "is
satisfied by *extruntime.Loader". It is not, and was not before this commit —
extruntime.LoadDir returns map[string]extruntime.Module, and Go requires
IDENTICAL result types, so an equivalent method set does not satisfy the
interface. The docs now say an adapter returning zip.Module is required.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 17:06:24 -07:00
zeekayandHanzo Dev 30885479ab call: the plane carries ZAP, not JSON
The op-call plane marshalled its body with encoding/json. That put a text
format inside the binary protocol: two services on one host, holding the same
struct in memory, serialized it to JSON and parsed it back to cross a socket.
JSON is the BOUNDARY encoding — it belongs on the REST routes a browser reaches
and in the MCP envelope an agent reads — and it has no business between our own
processes.

internal/zapenc derives a wire layout from the In/Out type itself: fields take
slots in declaration order, each aligned to its own width, which is the rule
zap's own schema builder applies. Nothing is named on the wire — a field IS its
offset — so the bytes on the socket are the bytes in memory, and the type
declaration is the schema both ends read. bool, every sized int/uint, float,
string, bytes, nested structs, pointers and slices cross; a map or an interface
is refused at encode rather than dropped, because a field that silently does
not cross is how a callee decides on a value nobody sent.

Refusals cross as ZAP too, status intact, so errors.As still recovers the
*HTTPError the callee returned.

op.invoke takes its decoder as a PARAMETER, so there is still exactly ONE
handler core under REST, MCP, CLI and the plane. The encoding belongs to the
transport; the contract is the same op either way.

The layout is the type, so the compatibility rule is: append fields at the end,
and only at the end. Reordering, inserting or retyping changes the wire.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 06:15:26 -07:00
zeekayandHanzo Dev 5835826a43 caller: forward the whole identity, and let a background caller state one
identityHeaders forwarded five of the nine headers a gateway asserts, so a
callee reached over the op-call plane saw a principal with no project, no
minted name, no owner and no org-admin bit — while the same handler reached
over REST saw all four. Two of them decide authority: owner carries membership
of a reserved platform org, and isOrgAdmin says only that someone administers
their own org. A subset let a callee read the second for the first, or find
neither, without anything on either side saying so.

Add WithCaller for the caller that has no inbound request to propagate. A
grant issued when an org opens and a meter that debits after the response has
gone out both act FOR a tenant; without a way to say which, the only place left
for the org is the argument, and an org in the argument is one the caller chose.
An inbound request still wins, so this supplies an identity where there is none
and cannot override one. CallerOf reads it back off the same context, so a job
sees what it is about to send.

Ctx gains Project, UserName, UserOwner and IsOrgAdmin, so the REST reader and
the call reader spell one vocabulary.

TestForwardedIdentityCrossesWhole fails on exactly the four dropped fields if
the array is narrowed again; the other three cover the stated path, the two
admin scopes staying apart, and an unattributed call staying anonymous.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 21:28:18 -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 9d386ae349 typed: OpScope is exported, because a decorating Router has to implement it
v1.18.0 sealed OpTarget with an unexported method, reasoning that nothing outside
zip should claim to be somewhere an op lives. That reasoning was wrong, and
building hanzoai/cloud against it is what said so: hanzoai/commerce's
middleware.Mint decorates a zip.Router — it prepends a platform-only gate to
every route registered through it, so that money-mint routes cannot be declared
without their gate. A sealed method made Mint uncompilable, with no fix available
to it from outside this package.

Worse than uncompilable is the version that compiles: a decorator that embeds the
wrapped Router and inherits its OpScope registers a typed money-mint op with NO
GATE. The decorator's entire purpose, silently skipped, on the routes that move
money.

So the method is exported and returns a value that says what it is —
OpScope{App, Prefix, Middleware} — and its doc says what a decorator owes: fold
your middleware into s.Middleware or the op you register is ungated. A struct
rather than three returns so a later field does not break implementors.

TestOpTarget_AnOutsideDecoratorCanCarryOps is the shape, written as an outside
package would write it: embed the Router, override OpScope, assert the gate
reaches a typed op. That test is what a sealed interface could not have.

My compatibility check for v1.18.0 grepped the local source trees and not the
module cache, which is why a published implementor got through. Building the
consumer is the check that finds this; the grep is not.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 17:30:41 -07:00
zeekayandHanzo Dev b1dc6ac252 docs: say what a group carries, what a DELETE carries, and what is gone
LLM.md leads with an upgrade note, because three of these are visible from
outside and an agent bumping the pin needs them before it reads anything else: a
typed Delete no longer reads a body, zip/zaprpc is deleted, and the document now
declares a URL-borne field's required-ness and carries a bodyless op's example.

The two "known bug" sections they replace are gone rather than edited — both
described drift that no longer exists, and a known-bug section that outlives the
bug is worse than none. What is left in their place is the rule: hasBody is what
a method carries ON THE WIRE, read by the route, the document, the CLI's remote
invoker and its flag derivation; op.invoke is unaffected because a call
addressed by name has no URL.

The zaprpc section stays, shrunk, because the fact worth keeping is not the
package — it is that cloud's `zaprpc` alias points at zap-proto/go/rpc, a
different module. Someone will meet that alias again.

Two statements the release made false are corrected rather than left: LLM.md and
migrate-from-gin both said a typed op cannot inherit a Group prefix, and
clispec.go said the document only ever emits one level of $ref. subsystem-mount
now shows the shape the note describes — a subsystem takes the Group it owns and
declares its ops on that, so the composition root never repeats the prefix.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 17:08:00 -07:00