6 Commits
Author SHA1 Message Date
zeekayandHanzo Dev 5f71d2496b The router is three nouns, and every one of them is concrete
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:

	users := app.Group("/v1/users")
	users.Post("/", api.Create)

	mint := users.Group("/mint").With(middleware.Mint(gate))
	mint.Post("/deposit", api.Deposit)

A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.

That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.

Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.



Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 01:39:17 -07:00
zeekayandHanzo Dev 7ec13236ff one verb: Graft, Add, Service and the Mount VERB are gone
Everything composes through Use. A unit of functionality is an *App — whether
it is linked in, loaded from a binary, or another process — so Load and Mount
become definition CONSTRUCTORS returning (*App, error) instead of verbs.

App.claim/release and the claims ledger go too: the walk's conflict accumulator
already answers 'who else wants this address', and answers it better — every
collision at once, with both claimants and their call sites named. Two
mechanisms for one question are two answers waiting to disagree.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 12:34:35 -07:00
zeekayandHanzo Dev bbabddeaa8 examples: declare typed ops, because that is what the examples are teaching
Ten of eleven examples declared their surface with untyped handlers. An untyped
route registers no op, so every one of them modelled the single pattern that
leaves an endpoint out of the OpenAPI document, out of the MCP tool list, out of
the CLI and out of the call plane — while the README two files away calls that
registry the one source of truth. The examples are where the default is learned,
and they were teaching the wrong one.

hello, subsystem-mount, migrate-from-chi, migrate-from-beego, plugin/billing,
plugin/host and internal/testplugin now declare typed ops. The migrate-from-*
examples show the port as the two steps it is: mechanical first (same handler
shape, one edit, and invisible to every projection), typed second, because
stopping after the first leaves exactly the surface you were migrating away
from.

sse-streaming and websocket stay untyped and now say why in the file header: a
typed op is one decode, one call, one serialize, and a stream has no single
value to return while an upgrade has no response body at all. That is the whole
list of reasons — a stream, an upgrade, a proxied byte range, a non-JSON body.

express-in-zip registered two routes on app.Fiber(), BELOW zip entirely: no
*zip.Ctx, no error handler, no middleware, no Authorizer, and no projection —
served by the app but not governed by it, which is a security seam and not a
shortcut. Both move onto the zip router. /runtime/:lang becomes a typed op, so
"run this source in this language" is now in the document and is a tool an agent
can call; the legacy JS wildcard stays a wildcard and says what that costs.

testplugin's catch-all stays untyped for the reason it exists: it answers paths
that do not exist, so a host mounting it at two prefixes can tell its own 404
from the plugin's. A wildcard is deliberately not one named endpoint with one
schema.

Every example was run and probed, not just built: 30 assertions across all
eleven, including the openapi/mcp routes the typed ops now light up.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 16:38:21 -07:00
zeekayandHanzo Dev 4ed2d9c1e4 feat(ci): publish the reference plugin per OS/arch on every tag
zip defines the install contract — URL requires Sum, the digest is verified
before the file is ever made executable, and it doubles as the cache key — so
zip is where a runnable proof of it belongs. examples/plugin/billing now ships
as a release artifact for linux/amd64, linux/arm64 and darwin/arm64, which means
any host can exercise its own install path against real published bits without
building anything.

It rides the pipeline this repo already has: one `binaries:` block in hanzo.yml,
read by hanzoai/ci. Nothing here grows a second release lane.

  https://github.com/zap-proto/zip/releases/download/<tag>/example-billing-<os>-<arch>
  https://github.com/zap-proto/zip/releases/download/<tag>/binaries.json   # url + sha256

That is the point of the URL form and it was documented only as a doc comment on
Plugin: a plugin is built ONCE, per OS/arch, and every host picks up the same
artifact. Neither side rebuilds for the other — the plugin's repo ships bits,
the host's deployment names them — and if URL and Sum come in as configuration,
even moving a host to a new plugin build stops being a host rebuild.

Docs say so where someone looks for it: examples/plugin/README.md (the lane, the
URL shape, why Sum is not optional, what the digest-keyed cache buys), the host
example's own comment, and LLM.md — which also stops claiming Plugin takes
exactly one of Addr/Bin/Path, and stops writing the pre-multi-prefix
Load(prefix, Plugin) signature.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 16:02:31 -07:00
zeekayandHanzo Dev 1d67323b50 feat!: a plugin can own more than one prefix
Load took a single prefix, so a service owning several route subtrees silently
404'd every one it could not name. Real services own several: o11y answers both
/v1/o11y and /v1/sentry, and extracting it as a plugin dropped /v1/sentry/*
entirely — the host started, reported healthy, and served nothing there. That is
the worst failure mode this API can have.

    zip.Load(zip.Plugin{Name: "o11y", Bin: bin}, "/v1/o11y", "/v1/sentry")

Prefixes are variadic and last, so one call declares everything the plugin owns
rather than a prefix field competing with an argument. Zero prefixes is refused
rather than mounting a plugin nowhere.

The test plugin now echoes the path it received, because a host that never
registered a prefix and a plugin that has no such route are indistinguishable
from outside otherwise — both are a 404. The echo is what tells them apart, and
the multi-prefix test asserts on it.

Found by the o11y extraction, which hit this immediately.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 15:31:58 -07:00
zeekayandHanzo Dev 1fe095e64e docs(examples): runnable plugin — separate binary, loaded over ZAP on a socket
Two binaries built independently: the plugin is an ordinary zip app whose only
plugin-specific line is zip.Addr, and the host composes it beside a linked-in
Service with the same verb while never importing its code.

Verified end to end — the host answers /health itself and /v1/billing/invoices
from the child process.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 09:35:46 -07:00