Commit Graph
162 Commits
Author SHA1 Message Date
zeekayandHanzo Dev cd8a5ff3ba measure the wrapper's allocation, not the request's
TestServePathAllocsAreChainDepthInvariant asserted a TOTAL of one allocation per
request and has been red for as long as the history shows, reporting 34 at every
chain depth — which is the invariant it is named for PASSING while the bound
fails. A bound nothing can satisfy gates nothing; it teaches a reader to skip a
red line.

A total counts everything a request pays and the wrapper owns almost none of it.
Replacing the logger with luxlog.NewNoOpLogger leaves 32, so the access log is 2,
and an alloc profile attributes the remaining 32 to telemetry: zip.describe with
its strings.Builder (~47% of objects, cumulative), the metric library's
labelsKeyFromLabels (~27%), the hex trace and span ids, net.IP.String and one
strconv.FormatInt. Per-request metrics and spans are a feature, so no arrangement
of the wrapper reaches one.

The claim in the doc comment is about DEPTH — the wrapper allocates per request,
not per handler — so that is what this measures now: five middlewares must cost
what none costs. The total is kept as a ratchet with a small margin for
sync.Pool's pinSlow, which the profile also shows.

Both assertions were watched failing before being trusted: an escaping allocation
in one middleware reports +1 at depth 1 and +5 at depth 5, and a ceiling of four
reports the 34.

Co-authored-by: Hanzo Dev <[email protected]>
v1.30.1
2026-08-14 23:07:10 -07:00
zeekayandHanzo Dev b53ddda13c bound how many plugin processes run at once
IdleAfter bounds the steady state and cannot bound the burst: it only reclaims a
plugin that has already been quiet for its whole window, so in the minutes after
a cold start — when every prefix that gets a request starts a child and none is
old enough to be idle — it reclaims nothing. Measured on a host of this shape,
37 children at ~152MiB each in steady state and an OOM kill six minutes after
boot, well before anything was 15 minutes idle.

Evict now takes that bound: age first, then, while more than warm plugins are
still running, the least recently used. The plugin that has gone longest without
a request is the one whose restart is least likely to be paid for by a caller
waiting. Zero is unbounded, which is what every existing host passes.

warm is an argument rather than a field because the number of processes a host
may hold is a fact about its memory budget, which the host knows and a library
cannot. Reap takes it for the same reason it takes its cadence.

A plugin with IdleAfter unset is exempt from both bounds, and still COUNTS
toward this one — it holds the same memory, and counting only the evictable ones
would authorise the budget twice. When the exempt alone exceed the bound this
reclaims what it can and stays over it, because a host is better over budget
than deprived of the identity service every other call goes through.

EvictIdle and ReapIdle are gone rather than kept beside these: two names for one
sweep is how the two policies come to disagree about how a child is taken down.

Co-authored-by: Hanzo Dev <[email protected]>
v1.30.0
2026-08-14 20:41:43 -07:00
zeekayandHanzo Dev 8533a1f758 reconcile the forge copy with the canonical line
This copy's main had been cut from a stale base: it was missing the v1.27.1
through v1.28.4 range and carried the idle-eviction work re-applied on top,
which is how a release cut from here published a tag that silently reverted
thirteen commits.

The tree is now github.com/zap-proto/zip's verbatim. Nothing is lost: idle.go,
idle_test.go and load.go were already byte-identical, and the one file that
differed, idle_bench_test.go, is BETTER upstream — it waits for the reap
instead of asserting residency the instant Running clears, which is a race that
passed on one machine and failed on the next.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-14 20:30:03 -07:00
zeekayandHanzo Dev f49059dad2 idle eviction, on v1.28.4 — v1.29.0 was cut from a stale base
v1.29.0 IS BROKEN AND THIS REPLACES IT. It was tagged from the forge mirror,
whose tags stop at v1.27.0, so it silently reverted v1.27.1..v1.28.4 —
including the zipdoc operation-id work — for anyone who took it. Cloud's gate
caught it immediately (operation ids came back as get_v1_* instead of get_*,
and openapi.yaml stopped being the weave of its apps). The tag stays where it
is; published versions are immutable, and this is the patch above it.

The feature itself is unchanged: IdleAfter stops a lazy plugin nothing has
asked for, and the next request starts it again through startOnDemand. Every
subsystem is its own process, so without this a host pays for the SIZE OF ITS
CATALOG rather than its traffic — measured on the fleet, 24 children at ~150MiB
of live heap each, ~4.4GiB, which is what evicted the pod and took its API down.

Safe against the supervisor by construction: it detects a deliberate stop by
CAS-ing cur to nil, and eviction clears cur before the child dies, so the CAS
fails and no restart storm follows.

The benchmark measures the claim from the kernel — six children warmed, VmRSS
read per pid, evicted, every pid asserted gone. It had to learn to WAIT: retire
is asynchronous by design, so Running goes false before the child is reaped,
and checking residency at that instant tests the scheduler. It passed on one
machine and failed on the next until it polled.

Verified on this base: idle suite green x3, 0 races in 5 -race stress runs.
TestServePathAllocsAreChainDepthInvariant fails identically with these changes
stashed — pre-existing on v1.28.4, unrelated.

Co-authored-by: Hanzo Dev <[email protected]>
v1.29.1
2026-08-14 16:21:01 -07:00
zeekayandHanzo Dev 9f72f4c139 measure what eviction reclaims, from the OS
The idle tests assert a plugin stopped and came back. Neither says the thing
eviction is FOR: that the memory went away. An implementation that dropped the
instance pointer and leaked the child would pass every one of them.

So this warms six plugins, reads VmRSS from /proc for each child, evicts, and
asserts every one of those pids is gone — zero resident, from the kernel rather
than from a counter the host keeps about itself. Then it serves a route again,
because eviction that took the catalog down would be a teardown.

Measured here: 68.1 MiB across 6 children, all of it reclaimed, 11.4 MiB each.
Those are trivial test plugins; the fleet's are ~150MiB apiece, which is the
number that makes a 100-surface catalog impossible to keep warm.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-14 16:12:43 -07:00
zooqueen e0c30f6485 transport: say what the body ceiling does, not what it did v1.28.4 2026-08-14 15:58:30 -07:00
zeekayandHanzo Dev 288b8472df idle eviction: the other half of Lazy
Lazy defers the first start and nothing has ever stopped a plugin, so a host
pays for every service it has EVER served, forever. Resident memory tracks the
size of the catalog rather than the traffic. Measured on a host running two
dozen subsystems: 24 children, ~150MiB of live heap each, ~4.4GiB resident,
while three of them sat at 12-20MiB. The catalog was the bill, and it is what
stops that host adding the other seventy services it wants.

IdleAfter stops a lazy plugin that has not served for that long; the next
request starts it again through startOnDemand, which already exists. Zero means
never, which is both the historical behaviour and the right setting for an
identity or config plugin every other call goes through — nobody should pay a
cold start for auth.

Safe against the supervisor by construction: it restarts a child whose process
exits and detects a deliberate stop by CAS-ing cur from the instance it watches
to nil. Eviction clears cur BEFORE the child dies, so that CAS always fails and
no restart storm follows — the same contract Reload and Unload use, minus the
disabled flag, because coming back is the point.

Two defects the checks caught before this shipped, both worth naming:

  - stop() from ReapIdle signalled and returned, so a sweep could still be
    running when the host began shutting down. That is a real data race
    (evictIfIdle reads App state shutdown writes) and the detector found it.
    stop() now waits for an in-flight sweep.
  - TestIdle_EagerIsNeverEvicted passed for the wrong reason: with no request
    served, lastUse stays zero and evictIfIdle returns on that check before it
    ever consults Lazy. Deleting the eager guard did not fail the suite. The
    test now serves a request first, and that mutation fails it.

Verified: 0 races in 10 -race stress runs; the eager, zero-means-never and
comes-back-with-a-new-pid guards each fail when their rule is removed.

(TestServePathAllocsAreChainDepthInvariant fails identically on this commit's
parent — pre-existing, unrelated, 34 allocs on the serve path.)

Co-authored-by: Hanzo Dev <[email protected]>
v1.29.0
2026-08-14 11:50:33 -07:00
zooqueen 98c206f054 the body ceiling reaches the socket, not just the Config
The HTTP transport builds a bare fasthttp.Server, so Config travels to the wire
only through applyConfig. It carried ReadBufferSize, WriteBufferSize and
Concurrency and left BodyLimit behind: fiberConfig sets fiber's own BodyLimit,
which fiber pushes onto MaxRequestBodySize only when fiber owns the listener,
and here the transport does. The socket kept fasthttp's 4 MiB default whatever
the App declared.

Measured on a deployment configured for 100 MiB: 4,194,304 bytes answered,
4,194,305 answered 400 "Error when parsing request" — fasthttp refusing before
any handler runs, in a voice that reads as a malformed payload rather than a
size cap. That capped every request body on the edge, so a site publish over
4 MiB and a full-context prompt (~4.3 MB of JSON for 1M tokens) failed alike.

The regression test drives a real socket in both directions, because the
assertion that passed throughout was a config-level one: the value was set
correctly and never left the struct.
v1.28.3
2026-08-14 09:23:15 -07:00
zeekay c2d8cf970f transport: listen plain HTTP beside a unix socket, so an upgrade lands
plain() derives the sibling address the host dials for an upgrade, but
nothing ever listened there: the dial was the only caller. A websocket
through a unix mount therefore failed the handshake against a socket no
process had created.

Listen now starts that listener for every unix address, next to the ops,
peer and MCP siblings it already brings up. The address stays derived, so
a mount is still one address.

The existing test passed the sibling address by hand and called that the
shape production runs. Production passes one address, so the test could
not fail. It now calls Listen the way a plugin main does, which is what
makes it cover the wiring.
v1.28.2
2026-08-13 18:09:48 -07:00
zeekay 2ba02d0c15 mcp: answer server/discover, and say 2026-07-28
MCP 2026-07-28 made the protocol stateless, and zip already was — there is no
Mcp-Session-Id anywhere in mcp.go, nothing is remembered between requests, and
tools/list answers without a preceding initialize. So the door met the new
revision's central requirement before the revision existed and went on
announcing the old one, which is the version a client negotiates against.

What was actually missing is one method. The new spec drops the mandatory
handshake, so `initialize` stops being a lifecycle step and becomes one more way
to ask "what are you"; `server/discover` is the name the spec gives that
question. It is optional, which is exactly why it has to be answered — a client
that uses it to inspect a server before committing got `method not found` from a
door that is otherwise fully stateless, and reads the whole thing as pre-2026.
Both names now return the same payload, and `initialize` stays for every client
still speaking the older revision.

Nothing needs a migration window: roots, sampling, logging and the HTTP+SSE
transport are what this revision deprecates, and zip never had any of them.
v1.28.1
2026-08-12 19:15:17 -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.
v1.28.0
2026-08-12 18:34:12 -07:00
zooqueen 268afe7914 http: a path is a socket here too
The relay reaches a plugin's plain-HTTP leg over a unix socket, and the built-in
HTTP transport could only bind tcp — so that address was read as a host named
/var/lib/cloud/run/… and never listened. A path names a socket on this transport
for the same reason it does on ZAP: the shape of the address says what kind of
place it is.

The test now runs the production shape end to end — a zip child on ZAP plus its
HTTP sibling, a real wsx socket echoing through a real mount.
v1.27.8
2026-08-10 23:18:39 -07:00
zooqueen d8b7f009a0 mount: relay an upgrade instead of parsing what follows it
A plugin's socket carries ZAP frames — one request, one reply — and an upgraded
connection is neither. The host wrote the 101 through and then kept parsing
HTTP, so the first frame the client sent arrived at the header parser as
nonsense and the connection died: "error when reading request headers", first
byte 0x81. Every websocket behind a mounted plugin failed on its first message,
and the session the plugin meant to run after the handshake never started.

An upgrade now goes to the plugin's plain-HTTP leg, a sibling of the socket it
already listens on, and past the switch the hop stops reading and moves bytes.
A plugin that declines to switch still answers its own reply, because refusing a
socket before it opens is how an unauthorized one is refused.
v1.27.7
2026-08-10 23:17:02 -07:00
zeekayandHanzo Dev f3d763a050 say it once
Co-authored-by: Hanzo Dev <[email protected]>
v1.27.6
2026-08-10 17:30:34 -07:00
zeekayandHanzo Dev 78b2cfe767 the transport imports as http, unaliased
zap-proto/http is package http now (v0.3.5) — the last element of its own module
path, which is the name Go resolves an unaliased import to. My v1.27.4 called it
zap, and that was wrong twice: it did not match the module path, and this package
imports zap, the wire format, so one word meant two things in the same file.

No file here imports net/http, so the transport needs no alias at all.

Co-authored-by: Hanzo Dev <[email protected]>
v1.27.5
2026-08-10 16:19:43 -07:00
zeekayandHanzo Dev 890d5c6538 the transport is zap, not zaphttp
zap-proto/http renamed its package (v0.3.3): the module path already says http,
so zaphttp said it twice and built a compound word into every file that touched
the wire. This module imports it under its own name now.

Co-authored-by: Hanzo Dev <[email protected]>
v1.27.4
2026-08-10 15:39:51 -07:00
zeekayandHanzo Dev f63305da8b the brand Host reaches a mounted child, end to end
Two halves of one path, and fixing only the first is why the last attempt looked
right and changed nothing.

forward() overwrote the Host header with the child's dial address. A client
dials its own address and never reads that header to find the far end, so the
value was routing nothing — it was destroying the one fact a callee needs to
serve more than one brand.

zap-proto/http then dropped it again: the encoder excluded Host from the headers
slot as "frame-owned" while nothing in the frame carried a host, and the decoder
reconstructed it from the very header the encoder had removed. Every request
over the ZAP wire arrived with an empty Host. Fixed in v0.3.2, required here.

TestMountedChildSeesTheCallersHost goes through a REAL mount rather than a
stubbed client, which is what the earlier unit test missed: it built its own
request, so it never met the serialization the server path takes. It fails on
either half alone.

Co-authored-by: Hanzo Dev <[email protected]>
v1.27.3
2026-08-10 15:19:42 -07:00
zeekayandHanzo Dev e7a4b06a6c the caller's Host survives the plugin hop, so a child can answer as the brand it was asked as
forward() overwrote the Host header with the DIAL ADDRESS on every proxied
request. A client dials its own address — HostClient.Addr, zaphttp.Dial(addr) —
and never reads this header to find the far end, so that value was not routing
anything. It was destroying the one fact a child needs to serve more than one
brand: which host was asked.

hanzoai/iam is where it showed. It serves every brand from one instance and
resolves each brand's OIDC issuer from the request Host, so mounted as a plugin
child it was asked as "/var/lib/cloud/run/iam.sock", matched no brand, and fell
back to its single default — lux.id and zoolabs.id minting tokens claiming
iss=hanzo.id, which their own relying parties reject. Config could not reach it:
the brand map was in the child's environment and the Host never arrived.

The dial address stays the fallback for a request carrying no Host of its own
(the MCP hop builds a fresh one), because HTTP/1.1 requires the header.

Read through req.Header.Host() rather than req.Host(): the latter goes through
the URI, which parses on first touch — 34 allocations on a hop that is meant to
cost none, which the alloc gate catches. And a request that already has a Host
is now left ALONE rather than re-set.

The test measures the SERIALIZED request. fasthttp writes the Host header from
the URI once the URI has been parsed, so reading either field in memory can
disagree with what actually goes over the wire; serializing is the only reading
that cannot. It fails on the old code and passes on this one.

Also: every test that binds a unix socket now does it in a short directory.
A socket address is 104 bytes on Darwin and t.TempDir spends most of them on the
test's own name, so 9 tests failed with "never began listening" — which reads as
a timing bug, is green on Linux, red on every Mac, and breaks when a test is
RENAMED. Suite goes 11 red to 2, both of which are pre-existing and platform:
SO_PEERCRED (no LOCAL_PEERCRED path on macOS) and the alloc gate, which fails on
pristine v1.27.1 when run in isolation.

Co-authored-by: Hanzo Dev <[email protected]>
v1.27.2
2026-08-10 11:20:19 -07:00
zeekay 4d8d856b6f zipdoc: a wrapped handler documents the handler, not the wrapper
The handler position takes `f(x)` in two unrelated shapes and handlerDoc read
both as a builder — a function returning the handler, whose own doc comment is
the only one there is. An ADAPTER has the same shape and the opposite meaning:
its argument IS the handler, and its doc describes the adapter.

So every operation registered through one published the adapter's sentence.
Measured on hanzoai/cloud: 55 operations across 24 apps described
`cloud.Handle` or `cloud.Terminal` instead of themselves — "Binds a
Service-scoped handler to a route", true of every route that uses it and about
none of them — while the sentence each handler had already written sat unread
one call inside.

The arguments tell the two apart, and nothing else has to: a builder takes what
the handler needs, a wrapper takes the handler. Recursing on the last func-typed
argument reaches through nested adapters and leaves the builder case exactly as
it was.
v1.27.1
2026-08-08 04:44:02 -07:00
zeekayandHanzo Dev 1e810926c5 mcp: derive readOnlyHint from the op's method
Every typed op became an MCP tool with {name, description, inputSchema} and
nothing else. An MCP client that cannot tell a read from a write must assume
WRITE, and Slackbot says so outright: an unclassified tool defaults to write
classification and prompts the user Allow / Always / Deny before EVERY call.

Measured on hanzoai/cloud's live surface: 1,323 tools, 0 with annotations, 0
with readOnlyHint -- so every read cost a confirmation. That is the difference
between an agent that answers and one that interrogates.

The answer already lived in the op: a GET is a read by construction, so no op
can be annotated inconsistently with the route it IS, and 1,323 tools are
classified without touching one of them.

HEAD rides with GET (a read whose body is discarded). Everything else is left
UNSAID rather than asserted safe: only the true half is emitted, because
readOnlyHint:false is the client's own default and writing it would claim we
had classified a mutation when all we know is that it is not a GET. An unknown
or custom method therefore fails toward caution.

go build ./... clean. The one failing test, TestServePathAllocsAreChainDepth-
Invariant (34 allocs/request), fails IDENTICALLY on unmodified main -- verified
by stashing -- so it is pre-existing and unrelated.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-05 22:38:29 -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.
v1.27.0
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]>
v1.26.1
2026-08-05 19:14:00 -07:00
zeekayandHanzo Dev 6c2674394d the id rule owes its own test
ID is now THE rule an operation's name is derived by — the document, the tool
list, the call plane and the command all read it — and it had no direct test.
Every case pinned here is a collision the encoding has to survive, not a
spelling preference: a parameter against a literal of the same name, the
hyphen that must not fold into the separator, the four spellings of one
parameter that must reach one name, and the wildcard that has no name of its
own. The '_' aliasing it CANNOT resolve is pinned too, so the day someone
tries to fix it there is a test saying the walk's uniqueness check is what
catches it.

cloud held the param-vs-literal case as a unit test of its own copy of the
rule. The copy is gone, so the test comes here, where the rule is.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-05 13:25:38 -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]>
v1.26.0
2026-08-05 11:12:39 -07:00
zooqueenandHanzo Dev d96b45b06b an argument addresses a resource, and may not address a route
Remote.Invoke percent-encodes every path argument (urlEscape) precisely so that
what a caller passes as a value cannot become part of the path. fasthttp then
undid it: its default serialisation is appendQuotedPath(u.Path()), and Path()
has already decoded %2F and resolved "..".

    Invoke(GET /v1/platform/apps/:app, {"app": "../../../v1/iam/users"})
      → GET /v1/iam/users

One operation's ARGUMENT addressing a different operation entirely, on whatever
credential the caller attached. Every decision made above this line — which
command was named, whether that command may run, whether it mutates — is made
about a request that is not the one sent.

Two settings, because two things have to be told and only one of them is read
per transport:

  Remote.do sets it on the REQUEST, so RequestURI() serialises PathOriginal() —
  the bytes Invoke built. This is what carries the property over ZAP (the
  default transport) and anything RegisterTransport adds.

  transport.go sets it on the http and https HostClients, because
  HostClient.doNonNilReqResp ASSIGNS req.URI().DisablePathNormalizing from its
  own field (fasthttp client.go:1631). On those two transports the request's own
  setting is overwritten, and the client's is the only one that survives. The
  request-level line alone measurably does not hold — that is how this was found.

Pinned on a live server across the cases that matter: a plain id arrives
unchanged, "../../.." arrives escaped as one segment, a bare "/" arrives as
%2F, a pre-encoded %2F arrives as %252F rather than collapsing a level per hop,
and a bodyless op's flags still ride the query string untouched.

Co-authored-by: Hanzo Dev <[email protected]>
v1.25.3
2026-08-05 05:32:37 -07:00
zeekayandHanzo Dev 277026897b a group has no name, so it must not answer for who wrote the op
Graft qualifies a composed type by the app that DECLARED it — iam.Application,
iam.Role — because two children may honestly both call a type Application and a
document that let the second overwrite the first publishes one name for two
shapes. Every generated SDK then binds whichever the merge read last.

composeOps asked the ENCLOSING definition for that name. App.Group returns an
App with its name stripped (groupConfig), and every real app declares its routes
in groups, so the question went to a nameless thing and the answer was nobody.
One level with no group still worked, which is why nothing said so: hanzoai/iam
publishes 345 schemas through groups and every one of them arrived in cloud's
fleet document unqualified, colliding with the host's own Application and Role.

The author is the nearest NAMED ancestor, carried down the walk as scope.origin
— computed once at the inclusion site, inherited by every group beneath it,
empty at the root so a program that composes nothing is byte-identical to what
it published before Origin existed.

Co-authored-by: Hanzo Dev <[email protected]>
v1.25.2
2026-08-05 01:49:06 -07:00
zooqueenandHanzo Dev cc8f15a88c metric v1.10.0 — the OTel bridge left the root package, and with it the SDK
luxfi/metric moved its OTel adapter to metric/otel, so importing the registry
no longer drags 34 OpenTelemetry SDK packages into every consumer. zip uses
only the root package — the registry, EncodeText, the ZAP exporter — so this
bump takes zip, and everything that inherits zip, to zero OTel packages
linked. Module graph pruning even drops them from consumers' go.sum. This
completes the ruling the telemetry work shipped under: nothing a plugin
inherits.

Co-authored-by: Hanzo Dev <[email protected]>
v1.25.1
2026-08-04 22:06:46 -07:00
zooqueenandHanzo Dev e570a16f6a telemetry: spans and the request record ship over native ZAP
The request boundary already holds everything a server span and a request
record contain, so both are structs filled from data it has: the trace, span
and parent ids it minted or joined, the route template as the name, start and
duration, an outcome-derived status, and the caller's org when a gateway
parked one. They are appended under one lock, drained once per tick and at
shutdown, and shipped as JSON in a ZAP envelope — internal/o11y declares the
batch shapes itself, no SDK, no protobuf, nothing a consumer inherits.

The keys are the collector's vocabulary, not ours. It reads a path out of
url.path / http.target / http.route / path in that order, so those are the
keys this fills; http.path, which an earlier draft shipped, fills nothing.
The contract/ module proves it the only way that stays true: it starts the
collector's real receivers, serves one real request, and asserts on the
structs those receivers produced. It is its own module because the collector
already requires zip — the proof would otherwise be a cycle.

The address is the switch, spelled O11Y_SPANS_ADDR / O11Y_LOGS_ADDR /
O11Y_METRICS_ADDR — three names because the collector binds one listener per
signal and promises no adjacency between them. The metrics name replaces
O11Y_METRICS_ZAP_ENDPOINT, which deployments had already stopped matching;
the socket preference is deleted because the collector only ever binds TCP,
so that branch could not run. Nothing stated means nothing starts: no
exporter, no node, no dial, no goroutine.

An app's own logging stays its own. Only zip's request line ships to the log
ear; teeing a program's every log line off the box is one decision for
luxfi/log to make once, for everything that logs.

Co-authored-by: Hanzo Dev <[email protected]>
v1.25.0
2026-08-04 21:49:59 -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]>
v1.24.8
2026-08-04 20:32:35 -07:00
zeekayandHanzo Dev 090bac90f1 zip.Mount is a proxy, not a mount — the name now says which of the two it is
`Mount` meant two unrelated things across the fleet at once:

  zip/remote.go    func Mount(prefix, addr string, decl ...Declaration) (*App, error)
  112 subsystems   func Mount(app cloud.Router, deps cloud.Deps) error

The second is the fleet's registration contract, stated in hanzoai/cloud's
package doc and checked by cloud.MountFunc. The first is a leftover: Mount was
one of five composition verbs (Listen/Mount/Add/Graft/Use), the other four were
dissolved into Use, and this one survived by being the only one that pointed at
another process. A reader seeing `Mount` in a diff could not tell which was
meant without counting arguments.

Renamed to Proxy, which is what it BUILDS. Every route it registers runs the
one handler this file calls `proxy`, whose whole body is forward() to the
address given; "proxy" is the term of art for a local stand-in whose handlers
hand the request to someone else, so it needs no gloss. It is also a noun,
which a function returning a value should be — composition is Use's job now,
and the doc has said so since the four verbs were dissolved.

NOT Remote: [Remote] already exists here and is the CALLER's side, a client
executing commands against a service elsewhere. This is the SERVER's side, a
definition standing in for that service inside this program's routing table.
Two sides of one wire are two things.

Also renamed the tests that named it, and mount_test.go -> adapt_net_http_test.go
with it: those pin AdaptNetHTTP, a THIRD sense of "mount", and TestMount_*
named none of the three.

Regression set empty: TestServePathAllocsAreChainDepthInvariant fails
identically on the parent commit (an allocation bound, untouched here).

Co-authored-by: Hanzo Dev <[email protected]>
v1.24.7
2026-08-04 19:30:42 -07:00
zooqueenandHanzo Dev 7ceef82acd an app's own instruments ride the one registry
Metrics returns the standard Registerer over the registry the framework's
request instruments already live in, so a domain gauge — a queue depth, a
fleet probe verdict — appears on the same /metrics page and travels the same
export as the built-in numbers. One registry per program, one road out,
nothing to configure. The caller holds no framework type: the standard
interface is the whole surface, and the registry stays owned here.

Co-authored-by: Hanzo Dev <[email protected]>
v1.24.6
2026-08-04 18:00:27 -07:00
zooqueenandHanzo Dev dbf00910f1 spans and logs travel the way metrics already do: native batches over ZAP
One protocol for the three signals, shapes declared once in internal/otlz and
pinned both directions against what the receiver accepts — a field invented
fails as loudly as a field missed. No OpenTelemetry SDK is linked by otlz;
importing the receiver for tests would have grown the module graph to 1145
modules, so the contract is goldens, measured not assumed.

Zero configuration. A program reports because it exists: each signal resolves
its collector by convention — an override if one is set, the well-known socket
if it exists, loopback's conventional port otherwise — re-resolved on every
attempt, so a collector that starts later is found without a restart. Absence
is free: lazy dial, the batch dropped, one line when export begins working and
one when it stops, with the reason. Over the socket the kernel attests which
process exported a batch, which loopback cannot.

Two races the tests forced out: the ship decision moved off the serve
goroutine, so a request answered immediately after Serve no longer loses its
span; and export starts in the last synchronous step Serve and Listen share,
so a short-lived process cannot reach Shutdown before the flush hook exists.

The runtime-directory scheme lives once in internal/sock; RuntimeDir and
SocketPath delegate to it. luxfi/zap moves one patch, v1.2.6 to v1.2.7.

Co-authored-by: Hanzo Dev <[email protected]>
v1.24.5
2026-08-04 16:51:09 -07:00
zooqueenandHanzo Dev 6cc782199b an app reports because it serves: telemetry is the framework's
Every program built on zip now emits one log line per request through its own
luxfi/log logger — method, path, status, duration, trace and span, plus the
caller when the environment parked one — and RED metrics through luxfi/metric,
with W3C trace context joined when a request carries it, minted when it does
not, and propagated on Call with no handler code. /metrics renders the one
registry, and export speaks the shipped ZAP exporter, which is what o11y
ingests. Config.Telemetry{Addr, Off} is the only switch; the default is on.

The boundary is installed outermost by materialise, so requests that match
nothing — 404s, preflights — are still counted; per-route composition cannot
see them. Two defects the tests forced out: the status is derived from the
handler's error, because at the boundary the response still reads 200 (fiber
writes the real status after unwinding, so every refusal would have recorded
as success); and the route label reads the not-found sentinel, because an
unmatched request leaves the middleware's own registration in place and would
otherwise be indistinguishable from a real root leaf.

middleware.Logger and middleware.Telemetry are deleted, not deprecated:
telemetry an app can lack is the defect this closes.

luxfi/metric moves to v1.9.1 — v1.9.0 is the published build whose registry
constructors sit behind a build tag nothing sets, so counters accepted Inc()
into a registry nothing gathers and /metrics served empty.

Co-authored-by: Hanzo Dev <[email protected]>
v1.24.4
2026-08-04 14:36:48 -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]>
v1.24.3
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]>
v1.24.2
2026-08-04 02:22:55 -07:00
zeekayandHanzo Dev 222d7f196d Test prepares, because it promises the generation serving would run
App.Test says it "builds the program if nothing is live, exactly as serving
would, so a test exercises the same generation a request would". It did not.
Serving calls prepare, which installs the DEFERRED PROJECTIONS — /mcp, the
OpenAPI document, the op-call plane, the plugin route. Building alone installs
none of them, so those four addresses answered 404 under Test and 200 in
production.

That is the worst shape a test can have: it cannot see the surface it was written
to check, and the closer it gets to the real program the more confidently it
reports the wrong thing. hanzoai/cloud's MCP tests only passed because callers
reached for an exported Prepare and remembered to call it — which is why
unexporting Prepare broke them rather than fixing them. The projections belong to
"run the program", not to the caller's discipline.

prepare is sync.Once, so a table test that calls Test in a loop still installs
them once; TestTestPreparesIdempotently holds that.

Co-authored-by: Hanzo Dev <[email protected]>
v1.24.1
2026-08-04 01:07:01 -07:00
zeekayandHanzo Dev 96eaf1c1e5 zip owns its TestConfig, so nothing has to import fiber to write a test
App.Test's doc comment tells callers to use it INSTEAD of reaching through
App.Fiber — and its signature took a fiber.TestConfig, so a test that merely
wanted a timeout imported the router this package exists to keep out of its
callers. The advice pointed back at the thing it replaced. hanzoai/cloud has 75
call sites doing exactly that, every one of them through the escape hatch, and
every one of them setting only Timeout and FailOnTimeout.

zip now declares those two fields itself. Two, because two are what the estate
uses: a knob nobody turns is a promise about the router underneath.

wrapRouter.Fiber() goes with it — dead since Router stopped requiring the method,
and the last place a decorator claimed to have a *fiber.App of its own.

What remains is App.Fiber and Ctx.Fiber: both documented escape hatches, both on a
CONCRETE type, neither on Router. That distinction is the containment rule, since
requiring fiber on the interface a decorator implements is what made it
unimplementable outside this package.

TestFiberDoesNotLeakIntoTheExportedSurface reads this package's own AST and holds
that line, with the two hatches named and justified. Verified by regression: put
fiber.TestConfig back on App.Test and it fails naming the exact signature. The
leaks arrive one convenience at a time and each looks reasonable alone, which is
why this is a test and not a review habit.

Co-authored-by: Hanzo Dev <[email protected]>
v1.24.0
2026-08-03 23:10:15 -07:00
zeekayandHanzo Dev 57d4cfa90a test: a definition has no absolute address until composition resolves it
Two assertions for the same fact, from opposite sides.

The first holds what every consumer depends on: whatever address the router
SERVES, the declaration NAMES. Those are computed by different code paths —
materialise walks the composition tree, composeOps recomputes each op's absolute
path through o.abs — and nothing in the type system makes them agree. Now
something does.

The second documents why a decorator cannot ask a Router "what is your prefix?"
and get a URL back: the question has no answer at registration time. The same
definition can be included at more than one site, so a group has no single
absolute prefix until a build resolves the tree. OpScope is a registration-time
value; its Prefix is the local join only.

That is not a gap, and it is worth a test because it reads like one. hanzoai/
commerce's mint layer asked exactly that question through the removed escape
hatch — r.Fiber().(*fiber.Group).Prefix — and got an answer only because fiber
flattened a group into one prefix at declaration. That approximation is silently
wrong the moment a definition is composed twice, which is the shape zip exists to
make ordinary. A consumer that needs the address a route is served at reads it
from the declaration, after composition.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-03 23:04:41 -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]>
v1.23.0
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]>
v1.22.0
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]>
v1.21.1
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]>
v1.21.0
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]>
v1.20.2
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]>
v1.20.1
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]>
v1.20.0
2026-08-02 13:56:18 -07:00
zooqueenandHanzo Dev 98d4837f67 router: Group returns the abstraction too, so a decorator survives nesting
Same defect as Fiber(), one layer down. v1.19 narrowed Group from Router to
*App; Go has no return-type covariance, so a concrete return forces EVERY
implementor to hand back an *App — and a decorator's group has to stay
decorated, which means returning itself around the group. v1.18 had this right
in all four places (Router, App, routerAdapter, wrapRouter).

compose.go already documents what narrowing costs: "Dropping it here reopened a
gate that origin/main held shut (wrapRouter.Group used to return another
wrapRouter, so the chain rode down every level) — an auth seam quietly lost at
the second nesting." That was patched INSIDE the package by copying a.wrap onto
the group. An external decorator has no such lever, which is why
hanzoai/commerce's mintRouter fails to satisfy Router on Group exactly as it
did on Fiber.

App.Group now widens to Router and delegates to an unexported group() that
still returns the concrete *App, so wrapRouter reaches g.wrap directly and no
assertion can silently drop the chain. Behaviour is unchanged.

The two red_test call sites that use App-level API on a group (Drop, Include)
now say so with an explicit assertion — asking for the concrete type is exactly
what they are doing.

Verified: build, vet, gofmt, the decorator guard, and the full suite measured
against a stashed baseline — 7 failures before, the same 7 after, zero new.

Co-authored-by: Hanzo Dev <[email protected]>
v1.19.2
2026-08-02 13:41:10 -07:00
zooqueenandHanzo Dev 7d22341185 router: the abstraction stops naming its implementation
Router carried `Fiber() *fiber.App` "for one-off escape". A Router is the type
a DECORATOR implements — OpTarget's own doc says so, and says how — and a
decorator wraps a Router, so it has no *fiber.App of its own to hand back.
Requiring one made the interface unimplementable outside this package: zip's
wrapRouter only ever delegated the method, hanzoai/cloud's `scope` and
hanzoai/commerce's `mintRouter` could not satisfy it at all, and a 157-file
v1.19 adoption in cloud stalled on a member nobody wanted.

The file already said the rule two lines up — "fiber.Ctx is the underlying type
the framework's users never see directly" — and then exposed *fiber.App. v1.19
made it worse than v1.18, which at least returned the fiber.Router interface
rather than the concrete app.

The escape hatch was never the problem; putting it on the ABSTRACTION was. It
stays on the concrete App, so a caller that genuinely needs the underlying
router still reaches it and has to hold an *App to do so, instead of every
Router in the estate quietly widening to fiber.

router_decorator_test.go is the guard: a decorator that embeds a Router, counts
registrations and imports no fiber, asserted against Router at compile time and
driven through a real registration so it cannot pass while swallowing routes. A
comment asking for the property is not the property.

Verified: build, vet and the decorator test pass. The suite's 6-7 socket and
release-fetch failures are pre-existing — measured against a stashed tree, the
failure sets are identical bar one flapping wire test.

Co-authored-by: Hanzo Dev <[email protected]>
v1.19.1
2026-08-02 13:35:42 -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]>
v1.19.0
2026-08-02 12:55:22 -07:00
zeekayandHanzo Dev 1ea512e127 phantom: Use is compose-time only; the freeze has no exemption
appendEntry allowed a write while a transaction was in flight. No transaction
path reaches appendEntry — compose, Drop and includeRoutes all append directly,
already holding buildMu — so the exemption could ONLY ever admit a Use racing a
transaction from another goroutine.

That append landed on the program and materialised on the next, unrelated
Include: a plugin load in one subsystem could activate a route appended hours
earlier by a caller that believed it had failed. Taking the lock made the
phantom reliable rather than absent, which is worse.

Use is compose-time only. Include is the one transactional live verb. Two
verbs, two lifetimes, neither silent. Both regression tests verified to fail
with the check disabled.

Also: the inert-middleware refusal now names the fix. A handler that ANSWERS an
address is registered at that address; Use is for handlers that WRAP. Use
cannot say WHERE a handler answers, which is the same argument that made Group
the way to express a scope rather than line position.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 12:34:35 -07:00
zeekayandHanzo Dev 3866f2728b mcp: the one door that dropped the caller
typed.go and call.go both build the handler's context with callerContext(fc);
mcp.go used fc.Context(), which carries no reference to the RequestCtx. So
CallerOf() came back EMPTY for any op dispatched by a tools/call — an
Authorizer could not tell who was asking, and an onward zip.Call from inside
such a handler forwarded no identity headers at all, so the next service saw an
anonymous request that had in fact arrived authenticated.

It is also why hanzoai/cloud grew a hand-installed Bridge() middleware at the
app root in ~100 places: middleware runs on the fiber Ctx, so parking the
identity there papered over the hole in that repo's private vocabulary.

The test fails with the bug and passes with the fix; both directions verified.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 12:34:35 -07:00