19 Commits
Author SHA1 Message Date
zeekay 9f1ab16dcc Tidy the two modules beside the root, so CI can build them
contract/ and examples/local-service/ named zip's go line and its
dependencies as they stood before websocket upgrades and go 1.27, so
`go test` in either refused with "updates to go.mod needed". The
collector test also registered two untyped handlers through Get, which
takes a typed one since generic methods; they are Raw now.
2026-10-18 20:08:59 -07:00
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 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
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
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]>
2026-08-04 14:36:48 -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 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 5dfa4ef616 plugin: a repo declares its routes, and the host discovers them
HIP-0106's plugin contract, in the framework rather than in 116 repositories.

Declaration projects the LIVE ROUTER — every method+pattern, complete by
construction, so a route a plugin serves and does not publish is still
declared. That is the analytics-ingestion outage class: the published paths
were listed, the four ingestion doors were not, and every product beacon in
the fleet answered 405.

  Declaration/Route/(*App).Declaration   what a host discovers
  /.well-known/zip/plugin.json           the same value off a running child
  (*App).Described                       `<bin> openapi <f>` / `<bin> declare <f>`
  Tenant                                 the tenancy read, with the real rule
  Delegate                               a continuation for another org
  Ask                                    dial a peer by name, invoke one op
  (*App).Peer                            peer ops on their own app, own socket
  (*App).Ops                             /healthz + /readyz + /metrics
  Config.Eager                           the one fact a router cannot show
  App.claim                              a second claim of a prefix is an ERROR
  zip/runtime -> zip/js                  a language runtime is an adaptor

Four things the reference plugin falsified while being built:

- Describe was already taken. zip.Describe(methodPath, Doc) is the doc
  registry generated code calls. The projection writer is (*App).Described,
  one call, because argv is read in one place and a main cannot forget to
  forward it.

- zip's control plane is NOT all under /.well-known/. /docs and the MCP path
  are not, so a prefix filter let a child declare them — paths outside /v1/
  that every plugin serves and whose first claimant takes the HOST's document
  and agent door. App.control now registers and records in one call, so
  leaking one is impossible rather than unlikely.

- OPS_PORT appeared in no Go file in the fleet. HIP-0119 named the paths and
  nobody implemented them, so conformance meant 116 repos hand-rolling three
  routes and a listener. It is one sibling app now, brought up by Listen.

- There is deliberately NO default ops port. A default made every second app
  in a process die on "address already in use" — a plugin has two apps in it
  and a test process has many. The deployment states OPS_PORT; a host states
  none on a child, so a child correctly owns none.

Load's duplicate claim used to return nil while the first registration kept
answering: the losing plugin's whole surface served another plugin's 404 and
nothing said so. It is now an error naming both claimants, all-or-nothing
across a plugin's prefixes, taken before anything is spawned.

a.ops was the op registry and is now a.registry — the ONE registry the
OpenAPI document, the MCP tool, the CLI command and the plane op are all
projected from. a.ops is the ops sibling.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 17:54:28 -07:00
zeekayandHanzo Dev 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 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
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 141d698fdc fix: Group carries the prefix, and a plugin need not extract into RAM
Two things, both found by real consumers breaking.

Fronting a net/http handler is Group's job, not Mount's. Mount delegates a
prefix to a REMOTE address over a transport, so it takes a string; a local
handler is composed, not delegated. The taught form is now:

    app.Group("/legacy").All("/*", zip.AdaptNetHTTP(h))

and for a net/http middleware, which IS middleware:

    app.Group("/legacy").Use(zip.AdaptNetHTTPMiddleware(mw))

Both keep the wildcard an ordinary route, so a route registered on the group
afterwards still beats it for its exact path. Repurposing Mount's old
(prefix, http.Handler) signature broke hanzoai/kms and lux-private/sovereign;
this is the form both were fixed to.

Plugin.Dir: Load extracted an embedded binary via os.MkdirTemp("") — the system
temp dir, which on many hosts is a tmpfs, i.e. RAM. A plugin binary is tens to
hundreds of megabytes, so that spends real memory and fails outright when the
tmpfs fills. Found the hard way: the reload test died with "no space left on
device" while other work shared /tmp. Callers can now put it on disk.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 11:09:40 -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
b6fafc3a4f zip: dissolve App.Mount into All+AdaptNetHTTP (deprecate; one composition path) (#5)
App.Mount(prefix, h) has always been exactly
a.All(prefix+"/*", AdaptNetHTTP(h)) -- two primitives that already exist.
Mark it Deprecated and point callers at the explicit form so there is ONE
way to put a route on the app. Mount stays as a behaviour-identical alias
(cloud still calls it); nothing breaks.

The mounted subtree is an ordinary wildcard route, so it participates in the
zap-proto/fiber fork's most-specific-wins precedence: a static route
registered AFTER the mount still beats it. New mount_test.go pins this as
the load-bearing proof, plus body/path-intact and the deprecated-alias
regression.

Docs/examples (README, migrate-from-chi, migrate-from-beego) updated to the
canonical app.All(prefix+"/*", zip.AdaptNetHTTP(h)) form; the buggy
AdaptNetHTTP doc example (which passed a zip.Handler to Mount) is fixed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-10 16:29:17 -07:00
5e4ca54293 zip: ride zap-proto/fiber v3.2.1 — specificity route precedence, natively (#2)
Swap the engine gofiber/fiber/v3 -> zap-proto/fiber/v3 (our fork: gofiber
v3.2.0 + ServeMux-1.22 route precedence fixed IN the router). Most-specific
pattern wins regardless of registration order, ambiguous equal-specificity
overlaps panic at registration, use/mount stay declaration-ordered barriers.
No zip-side buffering/finalize — the framework surface is unchanged; six
specificity tests pin the contract at the zip surface (engine-agnostic).

Kills the consumer order-int idiom (subsystem order ints existed only so
specific routes registered before wildcards).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-10 13:38:47 -07:00
zeekay fc15f4d1d5 feat: one-verb Listen (transport as value) + free MCP third projection
Decomplected the transport layer and added a free MCP tool surface — both from
the principle 'values, not places; one and only one way'.

TRANSPORT — one verb, transport is a VALUE (the address scheme), not a method:
  app.Listen(":9653")                  // ZAP (bare addr = the primary)
  app.Listen(":9653", "http://:8080")  // ZAP + HTTP in one call
  app.Listen("quic://:443")            // any RegisterTransport'd protocol
Mirrors net.Listen(network, addr). Removed ListenHTTP/ListenZAP/Serve — one Listen.
An open transport registry (RegisterTransport) means any future termination/
serialization proto slots in with ZERO API change. ZAP is DefaultScheme.

FREE MCP — every typed handler (Get/Post[In,Out]) auto-projects to a Model
Context Protocol tool at /mcp (JSON-RPC 2.0: initialize/tools/list/tools/call).
The SAME op registry feeds REST routes, the OpenAPI doc, AND the MCP tools — one
value (the op), three projections; tools/call runs the exact same fn as the REST
route (op.invoke, the transport-agnostic handler core). Because /mcp is an
ordinary route, it's served over every transport → ZAP-native MCP for free. On by
default; Config.MCP.Disabled to suppress.

Supersedes the same-session v1.0.0 transitional ListenZAP/HTTP/Serve API — forward
only, no compat shims (per repo policy: break within v1, never lazily bump major).

Verified: build/vet/gofmt clean; TestListen_ZAP + TestListen_DualTransport serve a
real request over ZAP (and both transports from one Listen); TestMCP_FreeToolSurface
drives initialize/tools/list/tools/call end-to-end (schemaOf projection + same-fn
call + validation→isError).
2026-07-01 23:42:42 -07:00
zeekay c581e0af7b feat: zip canonical home — the ZAP-native web framework, consolidated
zip is shared protocol infra (the fiber-over-ZAP web framework used by every
Lux/Hanzo/Zoo Go binary — nodes, gateway, cloud, iam, kms, o11y, ai, …), so its
home is the ZAP protocol family (zap-proto/{go,http,zapd,zip}), NOT any one org's
brand namespace. This consolidates the rich framework (formerly hanzoai/zip v0.5.0
— Ctx/Handler/Errorf/Err*/HTTPError, typed handlers, middleware, runtime/extension
loading, openapi, validate, zaprpc) with the real ZAP transport (ListenZAP/
ListenHTTP/Serve over zap-proto/http) into ONE canonical module.

Module path github.com/hanzoai/zip -> github.com/zap-proto/zip. Code identical to
hanzoai/zip v0.5.0; promoted to v1.0.0 as the stable shared-infra framework. The
prior hanzoai/zip lineage is retired — one home, one import path, one way.

Verified: go build/vet/test ./... green; the ZAP round-trip integration test serves
/v1/health over the real ZAP transport (200 + handler JSON).
2026-07-01 23:08:35 -07:00